Skip to content

Release v0.9.0 - #103

Merged
otsobide merged 10 commits into
mainfrom
dev
Aug 26, 2026
Merged

Release v0.9.0#103
otsobide merged 10 commits into
mainfrom
dev

Conversation

@otsobide

@otsobide otsobide commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Everything on dev since v0.8.0: three PRs in the remote and extraction paths, plus the version bump. Version strings and lockfiles are already at 0.9.0, which is what the release guard checks before it will publish.

Read this first if you are upgrading

One behaviour change. An archive whose listing cannot be read is now refused outright, where it used to extract whatever preceded the damage and then fail. A truncated tar that handed back three of its five files now hands back none.

That was deliberate. The partial extraction was not a feature, it was the naming layer switching itself off: every entry written before the fault went to disk with no rewriting, no refusal and no collision check, which on Windows turned notes.txt:hidden into an invisible NTFS stream without the user ever being asked (#89, and #63's harm without consent).

Salvaging is still possible and is not gone: the backends (extract_tar and friends) take no options and never go through the planning pass. It is simply no longer what extract does by default.

Faster, and no longer able to hang

#48. The poll loop slept a flat 200 ms before asking a second time, so a job the server had already finished still cost the caller that much. Measured with the real binary against a real server:

input before after
5 bytes 236, 233, 237 ms 44, 44, 42 ms
60 MB, ~8.3 s job 8347, 8550, 8562 ms 8271, 8479, 8297 ms

Not uniformly faster, and that is pinned rather than glossed: a job finishing just after the ramp is asked again a whole ceiling later, worst case 110 ms, bounded by one interval.

#71. No deadline, no maximum polls, 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: still polling after 60 s, nothing printed, nothing written. Now bounded by connect 10 s and read/write 30 s.

The limit is on the server's answers, never on the job. Compression may run for hours; what is not allowed is silence on an open socket. A test fails if a total deadline is ever added, because that mistake would cut off exactly the users who need a remote server most.

Unresponsive is a new error kept apart from Unreachable, because "the address is wrong or the server is down" and "the server is up and stuck" want different things from whoever reads them. Its message says the job may still be running on the far side.

Two smaller changes worth knowing

Error message text changed for remote failures, so anything matching on those strings will need updating. And the connect timeout is now 10 s where ureq's default was 30 s, which is a tightening rather than a pure addition.

New public surface

collapse-remote gains Timeouts, compress_path_with, check_health_with and the waiting module. The existing entry points keep their signatures, so the CLI and the desktop are untouched.

Also

bad.zip, 24 bytes of junk accidentally committed in #101, is removed.

Verification

604 Rust tests and 113 Vitest, on macOS, Linux and Windows. Two ureq behaviours were verified against the crate rather than assumed, one of which would have broken large transfers if guessed wrong.

What this does and does not close

An audit of this release caught two closing keywords that would have closed live bugs. Only one issue is genuinely finished.

Closes #89

#71 stays open, deliberately. Its title is "the poll loop has no upper bound, so a stuck job hangs the caller forever" and its primary reproduction is a server that answers compressing on every poll. That case is still unbounded, and it is unbounded by decision: the limit belongs on the server's answers, not on the job, because a genuine compression may run for hours. What landed is the other half, a server that goes silent on an open socket. The source says so itself at apps/remote/src/waiting.rs:99, and a live stub confirms it: the v0.9.0 client polled such a server until a 45 s guard killed it.

#48 stays open too. Its body names a second target: apps/server-frontend/src/api.js "has its own polling loop with the same shape and deserves the same treatment". Only the Rust client was fixed; the web app still sleeps a flat POLL_INTERVAL = 400.

Both will be narrowed to what actually remains rather than left implying nothing happened.

otsobide and others added 10 commits August 26, 2026 14:15
`wait_for_completion` slept a fixed 200 ms before asking a second time, so a
job the server had already finished still cost the caller that much of this
function sleeping. Measured with the real binary against a real server, a five
byte file:

  before:  236, 233, 237 ms
  after:    44,  44,  42 ms

The wait now starts at 10 ms and doubles to the old 200 ms ceiling, so the
schedule is 10, 20, 40, 80, 160, 200, 200, ... A long job is unaffected by
design: it reaches the ceiling after 310 ms and stays there, which the same
measurement confirms on a 60 MB incompressible input taking around 8.3 s
(8347, 8550, 8562 before against 8271, 8479, 8297 after). The whole price is
about three extra requests over the life of a job of any real length.

The schedule is a pure function in `protocol` rather than a counter inside the
loop, matching the split the rest of that module already has, so it can be
checked without a server and without waiting for it.

Tests: four in tests/protocol.rs pinning the first wait, the doubling, the
ceiling, monotonicity and saturation on a nonsense input; one in tests/client.rs
driving a stub that reports `compressing` three times, which takes 627 ms if
the fixed interval comes back and around 70 ms as it stands.

Closes #48
…ested

The backoff was only reachable through a socket and a real `thread::sleep`, so
the one test covering it drove a stub server and asserted on a wall clock with
a 400 ms margin. That proves the fixed interval has not come back and very
little else: it cannot say what the schedule *is*, and it gets slower and
flakier the more of it you try to cover.

`waiting` now holds the loop behind two one-method traits, `Sleeper` and
`Poller`, with `RealSleeper` and a private `HttpPoller` as the production
pair. Between them a test can state the thing no stub server can state
deterministically: a job that takes exactly N milliseconds. The fake sleeper
accumulates virtual time instead of spending it and the fake job reads that
accumulation to decide whether it has finished.

`wait_for` returns `Waited { polls, slept }`, its own account of what it did.
That is the part that keeps the tests honest: asserting on the return value is
asserting on the subject, while reaching into a fake to count calls is
asserting on the fake, and passes when the fake is wrong.

The schedule moves out of `protocol` with it. That module answers "what does
this JSON mean"; how often to ask is a different question and now has its own
file.

Ten cases in tests/waiting.rs, running in microseconds. All five mutations I
tried are caught by several of them each: starting at the ceiling, never
growing, sleeping before asking, not counting the settling poll, and removing
the ceiling.

Two of them exist to be honest rather than flattering. The backoff is not
uniformly faster: a job finishing just after the ramp is asked again a whole
ceiling later, so at 199 ms the old schedule finished at 200 and this one
finishes at 310. The bands are narrow, the worst case measured across 0 to
2000 ms is 110 ms, and it is bounded by one ceiling. Both the bound and the
gain are pinned.

The stub-server test stays. It is the only thing proving `RealSleeper` and
`HttpPoller` are actually wired in rather than the policy being right in
isolation.
Back off from a short first wait instead of a flat 200 ms
`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
`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.
Bound the server's answers, never the job
`plan_for` swallowed a listing failure and carried on with the identity plan.
The comment justifying that was right about the message and wrong about the
timing: the extractor fails **while streaming**, so by the time it notices it
has written every entry before the fault, and written them with no plan at
all. No rewriting, no refusal, no collision check.

Reproduced, same two entries, twice:

  intact listing    report Ok(1)   extract Err   ON DISK []
  unreadable        report Err     extract Err   ON DISK [notes.txt:hidden, second.txt]

On Windows `notes.txt:hidden` is not a file name, it is the `hidden` alternate
data stream of `notes.txt`: the write succeeds, the bytes land where no listing
shows them, and the user was told there was nothing to answer for. Issue #63's
harm, performed without consent, arranged by appending one bad 512 byte header
to a tar.

The message half of the old argument turned out to be the weak half. Both
passes go through the same parser, so they say close to the same thing; in the
reproduction they differed only in where they were cut off.

What this costs is partial recovery: a truncated tar used to hand back
whatever preceded the damage and now hands back nothing. Deliberate, and not
actually gone, since the backends take no options and never come through the
planning pass. `recovering_from_a_damaged_archive_is_still_possible_through_
the_backend` pins that so the capability does not look deleted.

Checked before committing to it that the listing parser is not stricter than
the extractor for archives that are merely unusual: a tar with no
end-of-archive marker and a zip with junk appended both list fine, so this
does not start refusing good archives.

Also makes the refusal readable, which is not scope creep but the consequence
of the change: it is now the whole of what the user gets for a damaged
archive, and the tar crate embeds the bytes it choked on.

  before: Compression failed: numeric field did not have utf-8 text: <4 bad
          bytes> when getting cksum for <100 more>
  after:  Compression failed: this archive could not be read, so nothing was
          extracted: numeric field did not have utf-8 text: when getting cksum for

Narrower than issue #66, which is about callers telling causes apart. This is
only about what is fit to show a person.

One desktop assertion moves with it, since the 7z message is now prefixed with
what happened before why.

Closes #89
Stop extracting an archive whose listing cannot be read
…landed

All eight version strings and the four lockfiles. The release guard checks two
of them (issue #77), so the rest are by hand and by eye.

Minor rather than patch because v0.9.0 changes observable behaviour: an
archive whose listing cannot be read is now refused outright instead of
extracting whatever preceded the damage. It also adds public surface to
collapse-remote (`Timeouts`, `compress_path_with`, `check_health_with`, and
the `waiting` module).

Not a bump: `bad.zip` is removed. It is 24 bytes of junk from a shell check
whose `cd` failed, so the file landed in the repository root instead of the
scratch directory, and `git add -A` swept it into #101. A release branch
should be a bump and nothing else, so this is called out rather than hidden:
it is a deletion of something that was never meant to exist.
@otsobide
otsobide merged commit faf1b9e into main Aug 26, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: an unreadable listing silently turns the whole naming layer off, and raw names are written

1 participant