fix(backup): bound the command result against the server's 1 MiB cap, not the IPC frame (#3001) - #3267
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: |
16c9132
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://270e16c2.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. |
…the unencodable-body path Review round on #3267. 1. The five PERSISTED warnings still hardcoded the IPC limit — the same bug this PR fixes in the log line, one layer further out. These strings land in result.warning -> backup_jobs.errorLog -> the UI, so after the binding limit moved to the 1 MiB server cap the headline 4,000-file repro would have told a customer that a 2 MB result exceeded a 16 MiB limit; oversizeFailureResult printed the actual size next to the limit it supposedly exceeded, making the contradiction visible in one line. The limit is now a value (deliveryLimit) threaded into every tier, so the warning, the failure text and the log line cannot tell different stories. It carries the enforced BUDGET separately from the enforcing party's CAP: degradation trips at 983,040 while the server allows 1,048,576, so reporting the cap as "exceeded" described a 1,000,000-byte payload as overflowing a limit it was under. Warnings name the budget; the log carries both. Five existing tests asserted the literal "IPC limit" as a proxy for "the tier explained itself", which had welded the suite to one specific limit being the trigger forever. They now assert the shared limitExceededPhrase. 2. The unencodable-body recovery in the WS backstop was dead code. SendResult marshalled the whole result first and returned on error, so boundResultFieldForServer's marshal-error branch was unreachable from the wired path and its unit test asserted a repair that never ran. A NaN in any nested map still lost the terminal status. SendResult now substitutes the marker and retries, and still errors when there is no body to drop. 3. commandResultResultByteLength re-serialised a just-parsed object with no size guard, on the one branch guaranteed to see unbounded input (no maxPayload is set; pre-fix agents send 64 MB results). Above 8 MB the frame size is reported instead — this repo has event-loop-stall form (#3236). 4. frameBytes used String.length (UTF-16 code units) — the exact confusion this PR's own test pins — and the echoed messageType/commandId came off an unvalidated message unbounded. Now Buffer.byteLength and clamped. Tests: the rejection frame is pinned from BOTH sides (Vitest asserts the shape the Go parser reads; a Go test asserts the field names against agentWs.ts and that the readPump error branch still precedes the id-less skip, the ordering the fix depends on). Plus IPC-frame attribution, budget-vs-cap reporting, the persisted-warning wording, and the wired NaN path. Both cross-language pin regexes are anchored on the declaration keyword so a doc comment cannot retarget them onto prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review round 2: code-reviewer + silent-failure-hunter + pr-test-analyzer. Nothing blocking; delivery correctness verified sound end-to-end. Four confirmed findings, all fixed in 1 (HIGH, found independently by two reviewers) — the operator-facing warnings still named the wrong limit. Five persisted strings in The limit is now a Correction to the finding: it is not a free change. Five existing tests asserted the literal 2 (MEDIUM) — the unencodable-body recovery was dead code. 3 — event-loop guard. 4 — the two nits. The rejection branch is extracted into an exported pure Tests (the pr-test-analyzer fast-follows). The error-frame contract is now pinned from both sides — the rated-most-valuable gap, since a rename of Verification: Status: review-clean, CI green, awaiting maintainer merge. The 1 MiB-vs-5 MB cap decision noted in the previous comment is still yours; issue #3001 stays open and assigned. |
… stdout/stderr (#3283) Follow-up to #3267, approved after that fix landed. Refs #3001 — no closing keyword; that issue's residual is already fixed and this only widens the good path. ## Why `result` was capped at 1 MiB while `stdout` and `stderr` in the **same schema** allowed 5,000,000. That split was not an incidental inconsistency — it is what caused #3001. The backup forwarder assigns its run body to `result` rather than `stdout` (`agent/internal/heartbeat/heartbeat.go`, `case TypeBackupResult`), so the payload silently inherited a limit five times tighter than the one anyone had reasoned about, and every backup over ~2,000 files lost its terminal result with no log on either side. Making the three equal removes the trap rather than documenting it. **Effect:** a snapshot keeps a browsable restore file index to **~9,500 files instead of ~2,000** (at ~522 B/entry). Concretely, the v0.104.0 QA reproduction — the 4,000-file run — now delivers its index intact, where it was previously rejected outright and then (post-#3267) degraded to a bare terminal status. The agent's tiered degradation is **unchanged** and still guarantees the terminal status past that point: a 100k-file index is ~52 MB and fits no sane cap. This widens the good path; it does not replace the machinery. ## Value: 5_000_000, not 5 * 1024 * 1024 Deliberate. The point is parity with the sibling caps, and rounding to `5 * 1024 * 1024` would leave `result` 242,880 bytes **looser** than `stdout` — a smaller version of the exact mismatch being fixed. Both sides carry a comment saying so, and a new test asserts the three caps are equal. ## Changes Both halves of the mirrored contract move together, plus both literal pins: - `apps/api/src/routes/agents/schemas.ts` — `MAX_COMMAND_RESULT_BYTES` 1_048_576 → 5_000_000 - `agent/internal/wire/limits.go` — `MaxCommandResultBytes` `1024 * 1024` → `5_000_000` - both literal pin assertions, and **both cross-language parsers**, which now accept digit separators — the Go literal gained underscores, and the Vitest parser silently read `5_000_000` as `5` until its character class was widened. Caught by the pin itself. - `CommandResultHeadroom` **stays at 64 KiB**: it is an absolute allowance for JSON re-encoding differences (HTML escaping, number formatting, string quoting), not a percentage of the payload. Scaling it with the cap would have widened it to 320 KiB for no reason. Documented at the constant. - comments/docs citing 1 MiB, 1,048,576, 983,040 or the ~2,008-file threshold updated in `result_bounds.go`, `wire/limits.go`, `schemas.ts` and the test narratives. Historical references are kept where they describe what actually happened, and marked as history. ## Fixtures: each still exercises the band it exists for The self-checking preconditions from #3267 did their job — every stale fixture failed loudly with an explicit message rather than going vacuously green. Two rounds of that caught both the resize list and a wrong per-entry estimate of mine (`buildLargeRunJob` encodes ~375 B/entry, not the field report's ~522). - **`TestFourThousandFileRunSendsItsIndexIntact`** — the QA reproduction, inverted. It now asserts the result arrives *whole*, and guards that the fixture is still over the *original* 1 MiB cap so it keeps representing the payload that reproduced #3001. A regression to either earlier behaviour (rejected, or degraded) is invisible without this. - **`TestOversizeIndexIsDegradedForTheServerCap`** — new 16,000-file (~6.0 MB) fixture keeping the degradation path pinned, in the band only the server cap owns (over the ~4.93 MB budget, under the ~15.9 MiB IPC budget). Without it the raise would have silently deleted coverage of the entire reason the tiers exist. - the non-object array fixture (150,000 elements) and the websocket-backstop fixture (20,000 entries) grew past the new budget; **"just under the budget" is now sized off the budget itself** rather than an entry count, so it stays meaningful at any cap. - new `equals the stdout/stderr caps in the same schema` test, plus one asserting the caps remain **independent** checks — a message may legitimately carry a full-size `stdout` *and* a full-size `result`, and a refactor folding them into one shared budget would break that. ## Confirmations requested - **WS frame path is fine:** no `maxPayload` is configured on the agent WebSocket server, so `ws` applies its 100 MiB default — 20x the new cap. - **Event-loop measurement guard still clears it:** `MAX_PRECISE_RESULT_MEASURE_BYTES` is 8,000,000, above the new 5,000,000 cap, so a rejected result is still measured precisely rather than falling into the unmeasured branch. Swept for other 1 MiB assumptions: the only nearby hits are unrelated — `CRITICAL_RESULT_STDOUT_MAX_BYTES` (a separate `stdout` limit on the critical-result validation path) and `TERMINAL_BYTES_LIMIT`. Neither touches `result`. ## Verification | Command | Result | |---|---| | `go test -race ./internal/wire/... ./internal/websocket/... ./cmd/breeze-backup/...` | pass | | `go test -race ./...` (whole agent module) | pass | | `go vet ./...` | clean | | `gofmt -l` on touched packages | clean | | `GOOS=windows` / `GOOS=linux` / native builds | clean | | `vitest run` — schemas, schemas.commandResult, agentWs (×3), commands, backupResultPersistence | 228 passed (8 files) | | `tsc --noEmit` (apps/api) | clean | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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