Skip to content

Bound the server's answers, never the job - #100

Merged
otsobide merged 2 commits into
devfrom
feature/poll-timeouts
Aug 26, 2026
Merged

Bound the server's answers, never the job#100
otsobide merged 2 commits into
devfrom
feature/poll-timeouts

Conversation

@otsobide

Copy link
Copy Markdown
Owner

Fixes #71, on top of #99.

The poll loop had no deadline, no maximum number of polls and no per-request timeout. A server that accepted the connection and then said nothing kept the client waiting for as long as the process lived: the issue measured it still polling after 60 s, having printed nothing and written nothing, killed by the harness.

The distinction this is built around

The limit is on the server's answers. It is not on the job.

Compression is allowed to run for as long as it takes. What is not allowed is silence on an open socket. So there is deliberately no total deadline on the poll loop, and a test fails if one is ever added:

3) a TOTAL deadline on the job     ->  FAILED. 18 passed; 1 failed

That mistake is an easy one to make while "fixing #71", and it would cut off exactly the users who need a remote server most.

Values

connect 10 s opening a socket at all
read 30 s any single read from an open socket
write 30 s any single write to an open socket

Generous on purpose. Hitting one has to be unambiguous evidence that something is wrong, not evidence that we were impatient, because the message is the only thing the user gets.

Two things verified against ureq rather than assumed

timeout_read is per socket operation, not per response. This is the one that would have quietly broken large transfers if I had guessed wrong. Probed directly:

timeout_read = 1000 ms
slow-but-steady 3s response -> Ok, 1000 bytes, took 2.741s

So a 500 MB archive on a slow link is fine as long as bytes keep moving. a_body_delivered_in_slow_pieces_is_not_mistaken_for_silence pins it.

ureq already separates the two failures worth telling apart:

connection refused  -> kind=ConnectionFailed   (505碌s)
connect timeout     -> kind=ConnectionFailed   (500ms)
dns failure         -> kind=Dns                (42ms)
silent server       -> kind=Io                 (1.004s)  "timed out reading response"

That becomes RemoteError::Unresponsive, kept apart from Unreachable: a wrong address or a server that is down is a different diagnosis from a server that is up and stuck. Its message says the job may still be running on the far side and that the archive was not downloaded, which is the true and useful thing to say.

Shape

Timeouts is injectable through compress_path_with and check_health_with, following collapse_core::extract_with. The defaults suit every front-end, and a suite cannot wait 30 seconds to prove what happens after 30 seconds of silence. Both existing entry points keep their signatures, so the CLI and the desktop are untouched.

Tests

Four new, 19 in the file, running in 0.95 s. Each was checked against a broken implementation:

mutation result
no timeouts on the agent the silent-server case takes 5.02 s and fails
a quiet socket reported as Unreachable the classification case fails
a total deadline on the loop the long-job case fails

a_long_job_is_never_cut_short_while_the_server_keeps_answering drives a stub that answers every poll at once but reports compressing eight times, so the client legitimately waits 910 ms against a 200 ms read timeout.

Counts: 600 Rust and 113 Vitest, 485 offline.

`wait_for_completion` had no deadline, no maximum number of polls and no
per-request timeout, so a server that accepted the connection and then said
nothing kept the client waiting for as long as the process lived. Measured
against v0.7.0 in the issue: still polling after 60 s, nothing printed,
nothing written, killed by the harness.

The limit added here is on the **server's responsiveness**, and deliberately
not on the job. Compression is allowed to take as long as it takes; what is
not allowed is silence on an open socket. There is no total deadline on the
poll loop, and `a_long_job_is_never_cut_short_while_the_server_keeps_answering`
fails if one is ever added: the mistake is easy to make and would cut off
exactly the users who need the server most.

`Timeouts` carries connect (10 s), read and write (30 s each) on one shared
agent. The values are generous on purpose. Hitting one has to be unambiguous
evidence that something is wrong rather than evidence that we were impatient,
because the only thing the user gets is the message.

Two properties were verified against ureq rather than assumed, and both matter:

- `timeout_read` and `timeout_write` are **per socket operation**, not per
  response. A body dribbled out over 2.7 s in fast chunks passes a 1 s read
  timeout untouched, so a large upload or download that keeps moving never
  trips one. A test pins it.
- ureq separates "no socket could be opened" (`ConnectionFailed` for both a
  refused connection and a connect timeout, `Dns` for a name that will not
  resolve) from "the socket was open and the exchange broke" (`Io`).

That separation becomes `RemoteError::Unresponsive`, kept apart from
`Unreachable` because the two want different things from whoever reads them:
a wrong address or a server that is down, against a server that is up and
stuck. Its message says the job may still be running on the far side and that
the archive was not downloaded, which is the true and useful thing to say.

`Timeouts` is injectable through `compress_path_with` and `check_health_with`,
following `collapse_core::extract_with`: the defaults suit every front-end,
and a suite cannot wait 30 seconds to prove what happens after 30 seconds of
silence. Both existing entry points keep their signatures, so the CLI and the
desktop are untouched.

Four tests, each checked against a broken implementation: dropping the
timeouts makes the silent-server case take 5 s and fail, reporting a quiet
socket as merely unreachable fails the classification case, and a total
deadline on the loop fails the long-job case.

Closes #71
@otsobide otsobide added the full-matrix Run the macOS and Windows suites on this PR label Aug 26, 2026
`a_body_delivered_in_slow_pieces_is_not_mistaken_for_silence` failed on the
macOS leg. Not the code: the test. It trickled a body in 60 ms pieces against
a 200 ms read timeout, and `thread::sleep` promises a floor rather than a
ceiling, so a shared runner turning one 60 ms gap into 250 ms was enough. A
margin that looked generous on a quiet laptop was 3x.

The same mistake was latent in two more cases, which passed only because they
got lucky: they used one short timeout for everything, including the cases
where the stub has to answer *inside* it.

So there are two helpers now, and the split is the point. `firing_timeouts`
keeps the short read for the cases where the timeout firing is the thing being
proved. `patient_timeouts` gives a second, at least a 25x margin over every
nominal gap, for the cases where the point is that nothing fires.

The long-job case grows from eight `compressing` answers to twelve, so it
still outlives the timeout it is measured against: 1710 ms of waiting against
1000 ms. The trickle case sends 30 pieces 40 ms apart, ~1200 ms in total,
which is past the read timeout, so a per-response limit would still fire where
a per-read one does not.

The suite goes from 0.95 s to 1.77 s. Worth it: six consecutive local runs are
identical, and all three mutations are still caught (no timeouts, a quiet
socket misreported as unreachable, and a total deadline on the loop).

This is real-socket behaviour, so unlike the poll schedule it cannot be moved
onto a virtual clock. Margin is the only defence available.
@otsobide
otsobide merged commit 4bf2234 into dev Aug 26, 2026
20 checks passed
@otsobide
otsobide deleted the feature/poll-timeouts branch August 26, 2026 13:22
@otsobide otsobide mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-matrix Run the macOS and Windows suites on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant