XERK-263: spool the migration relay's transcript bundle to disk - #437
Merged
Conversation
The hub buffered a moved session's whole gzipped transcript bundle in the heap — readRawBody at a 65 MiB cap — and then RETAINED it on the migration record for the entire importing phase, until the target agent pulled it back down. Two concurrent moves, which the Sessions page's Move control lets an operator start with two clicks, held 130 MiB in a hub that runs at mem_limit 256m. The relay now streams instead of buffering: - POST .../migrations/<id>/blob writes the body straight into a file under MIGRATE_SPOOL_DIR (/data/migrations, on the volume /data already needs), via a new spoolRawBody() that reads with backpressure and keeps readRawBody's over-cap drain rule so an oversize bundle still gets a 413 on the same connection rather than a socket hang-up. - The record keeps blobPath/blobSize, not bytes. The target's GET streams the file back with the recorded length. - dropMigrationBlob() unlinks on every terminal path — handoff, timeout, "source session gone", record eviction and retirement — and the unlink is safe mid-download, since the reader's open fd outlives the name. - Boot sweeps the spool dir: the records are in memory, so a restart abandons every in-flight move and anything left there belongs to no one. The wire contract is untouched (octet-stream in, octet-stream out), so no agent change is needed and older agents are unaffected. The tar-member ../absolute guards stay on the target, where they were. Spooling moves the pressure from the heap onto /data, which the archive shares, so MIGRATE_INFLIGHT_MAX (4) bounds how many moves can hold a bundle at once. It is enforced where a move STARTS, not on the relay upload: hub-agent.py's _migration_upload is best-effort with no retry, so refusing an upload would strand the migration, while refusing the operator's click is a message the Move control already shows. The ticket also suggested dropping XERK-258's refusable:false carve-out for this route. XERK-258 is not on main and has no open branch, so there is nothing here to drop; that cleanup belongs to whoever lands it.
An adversarial QA pass on the previous commit returned FAIL with two
reproducible defects, one of them a silent data-corruption path. Both are
fixed here, with a repro for each.
1. A spool write error that first surfaced during the FINAL flush was
swallowed: `out.end(() => resolve(len))` discards the end callback's
error, and `settled` was already set, so `out.on("error", fail)` was a
no-op too. The hub answered 200 {"ok":true} for a truncated bundle,
advanced the migration to `importing`, queued importSession at the
target, and logged nothing — the move then died at the 5-minute timeout
with no diagnostic. spoolRawBody now settles INSIDE the end callback,
fails on its error, and rejects on a short write (bytesWritten != bytes
received) rather than resolving with what came off the socket.
Repro (spool on a 1 MiB tmpfs with 12 KiB free, real hub):
before: 40960 bytes -> 200, 28672 on disk, phase importing, 0 log lines
after: 40960 bytes -> 500, no file, phase failed, no import queued
Every size from 16 KiB to 1 MiB now refuses; none leaves a partial file.
2. The bundle GET read `m.blobPath` at handler time but `m.blobSize` inside
the async `open` callback. A concurrent settle (heartbeat handoff,
timeout sweep, eviction) calls dropMigrationBlob, which zeroes both —
and since the unlink leaves the read's fd valid, the hub sent the whole
body under `Content-Length: 0`. The agent's urllib read it as an empty
bundle. Both are now snapshotted before the first async hop.
Repro (GET raced against a settling heartbeat, 300 iterations):
before: 296/300 served 524288 bytes under Content-Length: 0
after: 300/300 correctly framed; 40/40 via the real urllib client
Also from the same pass:
- sweepMigrationSpool deleted EVERY file it found in MIGRATE_SPOOL_DIR.
That dir is deployment config, and pointing it at /data — a one-word
compose slip — had boot delete state.json and devices.json (verified).
It now deletes only names matching the shape it writes.
- The spool-failure 500 handed the agent the hub's absolute filesystem
path in the error body; the detail goes to the log instead.
- The `bundle already uploading` 409 didn't drain the request body, so the
losing uploader saw a socket error instead of the refusal.
- The over-cap test sat on the exact RAW_BODY_DRAIN_SLACK boundary, one
byte from flaking; moved well inside it. The 413 holds only within the
slack (inherited from readRawBody) — stated plainly rather than claimed
unconditionally.
New tests cover each: the concurrent-upload guard (which no test caught —
removing it entirely was a clean mutation escape), an unwritable spool, the
download/settle race, and the sweep's selectivity.
QA also found that Android renders every non-2xx as "hub unreachable", so
the new 503 (and the pre-existing /migrate 409s) lose their wording on the
phone. Pre-existing and not Move-specific — it is FleetViewModel.run() for
every action — so it is filed as XERK-271 and logged in android/PARITY.md
rather than grown into this PR.
The re-QA pass returned PARTIAL: the product fixes all held under fresh attack, but the regression test guarding the subtlest of them — the GET that served a full body under Content-Length: 0 — caught a reintroduced bug only 1 run in 5, proven by reverting the fix and running the suite five times. It timed the settle from outside with a 1 ms sleep, which almost always lands after the response header is written; and when the drop won outright the test took its 404 early-out and passed just as silently. Both escape hatches let it pass in either direction. It now lands the settle in the window by construction, by patching the one call that opens it and dropping on the read stream's `open` — after the descriptor exists, so the unlink can't stop the read, and before the route's own open handler writes the header, because a listener registered inside the patch runs first. That is the production race exactly. It also asserts the settle landed, so the test can't quietly prove nothing. Same mutation, five runs: 5/5 caught (was 1/5). Two more from the same pass: - QA noted the D1 class survives for an error that only materialises at close(2): out.end(cb) fires before the descriptor closes (end-cb -> finish -> close), so a close failure would land on `error` after the resolve and be swallowed exactly as the flush error was. spoolRawBody now resolves on `close`. Unreachable on a local volume, but it is the same shape as the bug that failed the first QA pass, and closing it is two lines. Reverting the D1 fix now fails three tests in 13 ms rather than hanging the suite until the CI job times out. - The empty-bundle 400 is the one path that leaves a migration retriable, and it unlinked fire-and-forget — a retry that re-created the same path first would have had its good bundle deleted underneath it. The unlink now happens before the response. Also gave the "doesn't hang" test a 2s timeout of its own, so a route that stops answering fails there instead of by exhausting the job. Re-ran QA's own harnesses against the changed resolve path: the ENOSPC sweep still refuses every body that doesn't fit (500, no file, phase failed, no import queued), the 300-iteration settle race is 300/300 correctly framed, and a real 64 MiB relay is byte-identical. N3 in the report — a second hub sharing one MIGRATE_SPOOL_DIR sweeps the first's in-flight bundles at boot — is left as is: one hub per deployment is already assumed by state.json, devices.json and the archive index on the same volume, so the spool is not the thing to fix if that changes.
QA's final pass returned PASS with one nit worth taking: a route that stops answering hangs whichever test reaches it, and the suite then dies by exhausting the CI job's budget with no failing assertion to read. I had guarded exactly one test with a Promise.race; this moves the guard into the shared helper, so it covers every caller and every future one. Not reachable today — a path-based createReadStream always emits `open` or `error` — but it is the failure mode this change hit twice while it was being written, so the helper is the right place for it. Verified with the shape QA used to demonstrate it (an fd-based read stream, which never emits `open`): the suite now reports three assertion failures in 15s where it previously ran to the job timeout.
Conflicts in the migration blob relay, which XERK-266 hardened on main while this branch was moving it off the heap. Both sides' intent is kept. From main (XERK-266): the POST is scoped to the migration's own source host and the GET to its target host, and every POST refusal answers the same 404, so no response a non-source can't also get names the source to anyone holding the migration id. From this branch (XERK-263): the bundle spools to disk instead of riding in the record. Where they met, XERK-266's doctrine wins on the shape of every reply: - The `bundle already uploading` guard, the empty-bundle refusal and the `source session gone` branch all answer the uniform 404 rather than the 409/400 they had here. - A spool write failure does too, where this branch answered 500. It is NOT a second enumerated exception beside the 413: a distinct status would name the source the moment the hub's disk misbehaved. Nothing is lost — the agent only logs the reply, the record still carries "transcript bundle spool failed" for the operator, and the detail goes to the hub log where it is actionable. That also subsumes the path-leak fix from earlier in this branch: the body is now a fixed string. - The route's comment enumerates the two refusals this branch added, as that comment demands of anything reaching this route. The GET keeps main's `m.targetHost !== host` check in front of this branch's snapshot-then-stream. main's two new tests keep their assertions and move to the spool shape (`blobPath`, and reading the file rather than `m.blob`). This branch's three tests move to the uniform 404. One test-hygiene fix the merge exposed: the in-flight-cap case left the fleet at MIGRATE_INFLIGHT_MAX, so every later test that started a move got a 503. It now settles its own moves on the way out. Verified on a real hub after the merge: a 64 MiB relay is byte-identical with no retention (40.6 -> 63.3 MiB RSS), and a wrong host, an unknown id and an unwritable spool are indistinguishable on the wire while the record and the log keep the true reason. 1094 JS + 1288 Python green. Also closed XERK-271 and dropped the PARITY.md gap line it tracked: XERK-264 landed the same fix on main before I filed it, so Android already words hub refusals from the body via hubErrorMessage.
CI's Semgrep flagged the `path.join(MIGRATE_SPOOL_DIR, `${id}.bin`)` as a
possible path traversal. It isn't — the only caller passes `m.id`, minted
by crypto.randomBytes and never the agent's path segment — but the
function was documenting that as a fact about its caller rather than
enforcing it, which is exactly the kind of comment that goes stale when a
second caller appears.
It now validates the id against the shape startMigration mints and throws
otherwise, so the guarantee holds no matter who calls it, and the
`nosemgrep` sits behind a real check rather than an assertion of good
behaviour — the same idiom archive.js uses for the same rule. Tested
directly, since nothing should be able to reach it through a route.
Verified with the exact CI invocation locally: 0 findings, 541 rules,
301 files. 1094 JS + 1288 Python green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes XERK-263.
The problem
POST /api/agents/<host>/migrations/<id>/blobread a moved session's whole gzipped transcript bundle into the heap (readRawBody, capped at 65 MiB) and then retained it on the migration record for the entireimportingphase, until the target agent pulled it back down. The hub runs atmem_limit: 256m, and the operator can start two moves with two clicks of the Sessions page's Move control.Measured A/B on a real hub, one 64 MiB relay:
main(be96f6b)Seven concurrent 64 MiB bundles now sit at ~74 MiB RSS and fall back to 62 MiB idle. The remaining ~16–23 MiB is allocator high-water that varies run to run with no code difference — the 65 MiB retention is gone, which is the thing the ticket is about.
The change
The relay streams instead of buffering:
spoolRawBody(req, cap, filePath)writes the body straight to a file, reading with backpressure so a fast uploader can't just queue it all in the write buffer. It keepsreadRawBody's over-cap drain rule, and removes the partial file before its rejection lands.MIGRATE_SPOOL_DIR(/data/migrations, on the volume/dataalready needs — no compose change), keyed on the hub-minted migration id, never the URL segment.blobPath/blobSize, not bytes. The target's GET streams the file back.dropMigrationBlob()unlinks on every terminal path — handoff, timeout,source session gone,MIGRATIONS_MAXeviction, retirement. Safe mid-download: the unlink drops the name, the reader's fd keeps the bytes.The wire contract is untouched — octet-stream in, octet-stream out. No agent change; older agents are unaffected. The tar-member
../absolute guards stay on the target, where the ticket wanted them.Merged with XERK-266, which hardened this same route on main
XERK-266 landed while this was in QA: it scopes the POST to the migration's own source host and the GET to its target host, and makes every POST refusal answer the same 404, so no response a non-source can't also get names the source to anyone holding the migration id. Both sides are kept, and where they met XERK-266's doctrine wins on the shape of every reply — the
bundle already uploadingguard, the empty-bundle refusal andsource session goneall answer that uniform 404 rather than the 409/400 they had here.A spool write failure does too, where this branch answered 500. It is deliberately not a second enumerated exception beside the 413: a distinct status would name the source the moment the hub's disk misbehaved. Nothing is lost — the agent only logs the reply, the record still carries
transcript bundle spool failedfor the operator, and the detail goes to the hub log where it's actionable. That also subsumes the path-leak fix from earlier in this branch, since the body is now a fixed string. Verified on a real hub: a wrong host, an unknown id and an unwritable spool are byte-identical on the wire.One addition beyond the ticket's primary direction
Spooling moves the pressure off the heap and onto
/data, which the archive shares. SoMIGRATE_INFLIGHT_MAX(4) bounds how many moves can hold a bundle at once — the ticket's "secondary option", taken as a complement rather than an alternative. It is enforced where a move starts, not on the relay upload:hub-agent.py's_migration_uploadis best-effort with no retry, so refusing an upload strands the migration, while refusing the operator's click is a message the web Move control already shows.What the ticket asked for that isn't here
Dropping XERK-258's
refusable:falsecarve-out. XERK-258 is not on main and has no branch (it's being worked in parallel), so there is nothing here to remove — that cleanup belongs to whoever lands it.QA
Three adversarial passes (
~/.claude/agents/qa.md). The first returned FAIL and was right to.Defects it found in my first attempt, all fixed here:
out.end(() => resolve(len))discards the callback's error, andsettledwas already set so theerrorhandler was a no-op too. The hub answered200 {"ok":true}for a truncated bundle, advanced toimporting, queuedimportSession, and logged nothing; the move then died at the 5-minute timeout with no diagnostic. Repro on a spool FS with 12 KiB free:40960 bytes -> 200, 28672 on disk. Now:500, no file,phase=failed, no import queued.spoolRawBodysettles inside the end callback, and resolves onclose— the descriptor is still open in the end callback, so aclose(2)failure would land on the same swallowed side.Content-Lengthdidn't describe it. The GET readblobPathat handler time butblobSizeinside the asyncopencallback; a concurrent settle zeroes both, and since the unlink leaves the fd valid the hub sent the whole body underContent-Length: 0— which the agent'surllibreads as an empty bundle. 296/300 in QA's race harness. Both fields are now snapshotted before the first async hop: 300/300 correct, and 40/40 through the real Python client.MIGRATE_SPOOL_DIRis deployment config; pointed at/databy a one-word compose slip, boot deletedstate.jsonanddevices.json(verified). It now matches/^[0-9a-f]{16}\.bin$/.bundle already uploading409 didn't drain the body, so the losing uploader got a socket error instead of the refusal.The second pass returned PARTIAL — not for the product code, which held under fresh attack, but because my regression test for #2 caught a reintroduced bug only 1 run in 5. It timed the settle from outside with a 1 ms sleep, and took a 404 early-out when the drop won. It now lands the settle in the window by construction, on the read stream's
openevent, and asserts that it landed: 12/12 caught.The third pass returned PASS. Its remaining nit is taken inline:
requestRawhad no timeout, so an unanswerable route hung the suite until the CI job died. The guard moved into the shared helper.Verified in that final pass, on real hubs over real sockets: the ENOSPC sweep, EISDIR, the over-cap boundary (413 within
RAW_BODY_DRAIN_SLACK, socket cut past it — inherited fromreadRawBody; a conforming agent can't reach it, since it caps its own bundle 1 MiB below the hub's), aborted uploads, empty bodies, concurrent 32 MiB uploads,source session gone, seven successive 64 MiB relays, the in-flight cap with a real handoff freeing its slot, and — closing the biggest gap from pass 1 — the realhub-agent.pylegs:_pack_transcript→_migration_upload→_migration_download→_unpack_transcriptbyte-identical, torn tail dropped, and a malicious tar refused withunsafe tar member '../../…'and nothing written.Not verified, stated plainly: no move has ever been completed by a live agent pair — the command loop, tmux launch, worktree creation and
claude --resumewere never driven. That is a standing gap in the migration feature, not in this diff.close(2)failures are structurally closed but not exercised (local volume). RSS was measured unconstrained, not under a real 256 MiB cgroup. Android/glasses/veiller/the browser UI were read, not run.CI
Semgrep flagged
path.join(MIGRATE_SPOOL_DIR, ...)as a possible traversal. It wasn't — the only caller passes the hub-mintedm.id— but the function documented that as a fact about its caller rather than enforcing it, which is the kind of comment that goes stale when a second caller appears.migrationSpoolPathnow validates the id against the shapestartMigrationmints and throws otherwise, so thenosemgrepsits behind a real check (the same idiomarchive.jsuses for the same rule). Verified with CI's exact invocation locally: 0 findings.Tests
New cases in
turma/tests/server.test.js: spool-on-disk on the happy path, the empty body, the over-cap 413 (sizedcap + 4096, well inside the drain slack so it can't sit one byte from flaking), the concurrent-upload guard (which no test caught — removing it was a clean mutation escape), an unwritable spool, the download/settle race, the sweep's selectivity, the boot sweep, the in-flight cap freeing its slot, and the spool-path guard. 1095 JS + 1288 Python green.One test-hygiene fix the merge exposed: the in-flight-cap case left the fleet at
MIGRATE_INFLIGHT_MAX, so every later test that started a move got a 503. It settles its own moves on the way out now.Findings routed elsewhere
FleetViewModel.run()rendered every non-2xx as "✗ hub unreachable", so the new 503 would have lost its wording on the phone. XERK-264 had already landed the same fix on main (hubErrorMessagereads the{error}off anHttpExceptionor a typedResponse), so the merge closed it for free. The ticket is closed with that explanation and thePARITY.mdgap line I'd added is removed — the entry now records that Move's refusals reach the operator in the hub's own words like every other command.MIGRATE_SPOOL_DIRsweeps the first's in-flight bundles at boot. One hub per volume is already assumed bystate.json,devices.jsonand the archive index; if that changes, the spool isn't where to start.