fix(backup): bound the command result against the server's 1 MiB cap, not the IPC frame (#3001) - #3267
fix(backup): bound the command result against the server's 1 MiB cap, not the IPC frame (#3001)#3267ToddHebebrand wants to merge 2 commits into
Conversation
… not the IPC frame (#3001) The #3001 residual, reproduced on v0.104.0: a 4,000-file backup completes on the endpoint, its terminal result never reaches the API, and the stale-backup reaper fails a job that succeeded. A 1,200-file run lands normally. Nothing is logged anywhere on either side. Root cause. The result is refused by `commandResultSchema`'s 1 MiB cap on the `result` field (apps/api/src/routes/agents/schemas.ts). The backup helper's stdout is the full BackupJob JSON including one `snapshot.files` entry per backed-up file (~522 B each), and the forwarder assigns that body to `result` rather than `stdout` — which has a 5 MB budget. 1048576/522 puts the cliff at ~2,008 files, exactly inside the observed 1,200-passes / 4,000-fails bracket. The silence had three independent causes, all fixed here: - the helper's tiered degradation bounded against the 16 MiB IPC frame — the next hop, not the binding limit — so it never fired and never logged; - the server logged the rejection as a generic invalid-message with no commandId, no size and no type, while both backup-specific log lines live downstream of the failed parse and never ran; - the server's error frame carries no `id`, so the agent's readPump discarded it under the "not a command" skip. Changes: - agent/internal/wire: new leaf package mirroring the server's cap, pinned to the TypeScript declaration by a test on each side. - helper: bound Stdout against the server budget as well as the IPC frame, so tier 2 drops the per-file index at the limit that actually binds. A 100k-file backup now reports completion with its snapshot identity intact. - WS client: generic backstop replacing an over-cap `result` body with a marker so ANY command type keeps its terminal status; and readPump now logs server rejections instead of dropping them. - server: a rejected command_result logs at error with commandId, frame size, measured result size and the limit, and echoes commandId in the reply. - the degradation log line names the limit that actually fired instead of always claiming the IPC frame (it reported limitBytes=16777216 for a 10 KB payload truncated by the 8 KiB stderr cap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
88a32ea
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://7dd6a3ef.breeze-9te.pages.dev |
| Branch Preview URL: | https://toddhebebrand-fix-3001-silen.breeze-9te.pages.dev |
…not the bare cap Review finding on #3267. boundResultFieldForServer and the SendResult short-circuit compared against wire.MaxCommandResultBytes, so the one guard that protects every NON-backup command type ran with no margin for the server's JSON.stringify re-measurement. A body landing in the 64 KiB band below the cap could pass here and still be refused on arrival — the exact silent loss this PR closes, for the commands with no producer-side bounding. Both comparisons now use wire.CommandResultBudget; the bare cap is kept only for reporting the server's contract in logs and the omission marker. TestBoundResultFieldUsesTheBudgetNotTheBareCap pins the choice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 1 raised → addressed in
Tests: Open decision for the maintainer: the Status: review-clean, CI green, awaiting maintainer merge. Issue #3001 left open and assigned. |
Fixes the residual reported in #3001 (re-confirmed on v0.104.0 /
a3dc568ecduring release QA). The original 64 MB-vs-16 MiB IPC oversize path was closed by #3004/#3037; this is the second, silent loss path underneath it.Confirmed root cause
The terminal result is refused by the server's 1 MiB cap on the
resultfield —commandResultSchemainapps/api/src/routes/agents/schemas.ts— not by anything on the agent.The backup helper's stdout is the full
BackupJobJSON including onesnapshot.filesentry per backed-up file (~522 B: source path + backup path + sha256 + modTime). The forwarder (agent/internal/heartbeat/heartbeat.go,case TypeBackupResult) parses that body and assigns it toCommandResult.Result, i.e. the wireresultfield — not tostdout, which has a 5 MB budget. So:which sits exactly inside the observed bracket: 1,200 files (~0.6 MB) lands, 4,000 files (~2.1 MB) is refused. It is the tightest limit anywhere on the path, and by a wide margin — 16 MiB agent IPC frame, 16 MiB agent WS read limit, 100 MiB
wsservermaxPayload.The prime hypothesis in the issue (a WS max-payload) is ruled out.
@hono/node-wscreates itsWebSocketServerwith nomaxPayload, sowsapplies its 100 MiB default; the ~2 MB frame arrives intact. No proxy layer caps it either. The frame is dropped at the application layer, onesafeParsebefore the backup handler.Why it was silent at every layer
Three independent causes, all fixed here:
ipc.MaxMessageSize - 64 KiB≈ 15.9 MiB — the next hop, not the destination. A 2 MB result cleared it untouched, so no tier ran and no degradation line was logged.agentWs.tslogged the rejection as a genericInvalid message from agent <id>:carrying only the raw Zod issues — no commandId, no size, no message type. Both backup-specific lines (Processing backup result/Dropping backup result) live insideprocessCommandResult, downstream of the failed parse, so neither could ever print. Grepping for them reads as "the frame vanished".{type:'error', code:'INVALID_MESSAGE'}reply carries noid, soreadPumpdiscarded it under the "not a command" skip. The write had genuinely succeeded, so every send path reported success.The fix
1. Make the terminal result survive. New leaf package
agent/internal/wireholds the server's cap.result_bounds.gonow boundsStdoutagainstwire.CommandResultBudgetas well as the IPC frame, so tier 2 empties the per-file index at the limit that actually binds. A 100k-file backup degrades to scalars-plus-snapshot-identity and reports completion; a 1,200-file backup is still sent byte-for-byte intact with its full file index.The rule the change encodes: bound against the tightest limit anywhere on the path, never the one nearest to hand.
2. Kill the silence.
command_resultnow logs at error with commandId, frame bytes, measuredresultbytes and the limit, and says plainly that the job will be reaped. Everything else stays a warning. The error reply echoescommandIdandmessageType.readPumphandles inbounderrorframes and logs them at error with the server's code and details.SendResult— if theresultbody still exceeds the cap, it is replaced with a_breezeResultOmittedmarker and logged at error, so the terminal status lands regardless. This is deliberately not backup-specific: software inventory, patch scans and filesystem analysis are allresultbodies that scale with the endpoint and share the same exposure.3. The misleading log line.
sendBackupResultreportedlimitBytes=16777216unconditionally, so a 10 KB payload truncated by the 8 KiBmaxResultTextBytesstderr cap was described as overflowing a 16 MiB frame. Attribution is now tracked as the tiers run and reported aslimitName+limitBytes.4. Regression tests at the layer the cause lives in — see below.
fitBackupResultToIPCis renamedfitBackupResultForDelivery: the old name asserted the exact wrong thing about which limit matters, and that assumption is what shipped this bug.Not raising the cap — needs your sign-off
The
resultcap stays at 1 MiB. A larger cap only moves the cliff (a 100k-file index is ~52 MB and fits no sane limit), and the agent-side bound is the actual fix. There is a defensible argument for raising it to 5 MB to matchstdout/stderr— it would preserve restore browsing for ~5x more endpoints, and the inconsistency is arguably what caused this — but that is a security-surface change, so it is left as a one-line, mirrored decision rather than made here.Related: the new loud logging will likely reveal other command types already hitting this cap silently. Worth watching the first week of
REJECTED command_resultlines.Verification
go test -race ./cmd/breeze-backup/... ./internal/websocket/... ./internal/wire/... ./internal/heartbeat/... ./internal/ipc/...go vet+gofmt -lon changed packagesGOOS=windows go build ./...,GOOS=linux go build ./...vitest run schemas.commandResult.test.ts schemas.test.ts schemas.heartbeatTolerance.test.ts commands.test.tsvitest run agentWs.test.ts agentWs.enqueueContract.test.ts agentWs.terminalResultSchema.test.tstsc --noEmit(apps/api)New tests:
TestFourThousandFileRunIsDegradedForTheServerCap— the QA reproduction. Asserts the fixture is a size the old IPC-only bounding accepted, then that it is now degraded, attributed to the server cap, with the file index as the thing dropped.TestTwelveHundredFileRunIsSentIntact— the other half: the run that worked must keep its full index. Guards against a fix that degrades everything.TestHundredThousandFileRunStillReportsCompletion— requirement 1 as a test, including snapshot identity survival.TestStderrOnlyDegradationNamesTheTextCap— requirement 3.TestMaxCommandResultBytesMatchesServerSchema(Go) parses the TypeScript declaration;schemas.commandResult.test.tsparses the Go one. The cap is pinned from both directions, so raising one alone reddens CI rather than quietly re-opening this issue.boundResultFieldForServercoverage: oversize dropped with status preserved, in-budget untouched, unmarshallable handled, andSendResultproven to bound before enqueue.🤖 Generated with Claude Code