Skip to content

fix: skip non-event persistence records and enforce body-size limit in bytes - #62

Merged
jonbaldie merged 2 commits into
mainfrom
agent/fix-persist-null-and-utf8-body-limit
Aug 27, 2026
Merged

fix: skip non-event persistence records and enforce body-size limit in bytes#62
jonbaldie merged 2 commits into
mainfrom
agent/fix-persist-null-and-utf8-body-limit

Conversation

@jonbaldie

Copy link
Copy Markdown
Owner

Two confirmed bugs found via a coverage-guided property/fuzz campaign against the public HTTP and --persist seams. Each has a minimized, reproducible counterexample and a regression test.

Bug 1 — Startup crash on a null persistence record (src/persist.ts)

FileStore.loadState() parsed every non-empty line as JSON and replayed it unconditionally. A single valid-JSON null line reached Manager.applyLoadedEvent, which dereferenced null.enqueue and threw a TypeError that aborted server startup before it could listen.

  • Repro: persist.dat containing the four bytes nullmain.ts --persist exits with code 1 and no listener.
  • Root cause: no event-shape validation at the persistence decode boundary.
  • Fix: an isQueueEvent() type guard rejects non-conforming records (non-objects, wrong field types, or enqueue == dequeue) so they are skipped like malformed JSON.
  • Regression test: starts a real main.ts --persist process against null and asserts /health and /queues still respond.

Bug 2 — Multi-byte UTF-8 body evades the 1 MiB size limit (src/handler.ts)

readRequestBody's backstop checked body.length after request.text(), which counts UTF-16 code units, not wire bytes. A chunked (no Content-Length) UTF-8 body of 1.2 MiB bytes but 400K code units bypassed the byte limit and was enqueued with 200 instead of 413. String bodies were unaffected because Deno sets Content-Length to the byte length, so only chunked requests evaded the check.

  • Repro: streaming fetch body of {"payload":"一".repeat(400000)} (1,200,014 bytes, 400,014 code units), no Content-Length200.
  • Root cause: the limit was measured in code units, not bytes.
  • Fix: re-measure via LOG_ENCODER.encode(body).length (bytes), reusing the existing module-level TextEncoder so no new coupling is introduced.
  • Regression test: sends a streaming multi-byte body with no Content-Length and asserts 413.

Diagnosis

Followed /diagnosing-bugs: tight red-capable loop -> minimized repro -> ranked falsifiable hypotheses -> confirmed root cause -> fix + regression test -> original repro no longer reproduces.

CI verification (local)

  • deno test --allow-read --allow-write --allow-net --allow-env --allow-run: 374 passed
  • Mutasaurus: 99% overall, all files >=80% (handler 100%, persist 100%)
  • Stryker: all files >=80% (handler 89%, persist 84%), stryker_check exit 0
  • Production quality gate: exit 0, no new warnings
  • deno compile main.ts: exit 0

🤖 Generated with Claude Code

jonbaldie and others added 2 commits August 27, 2026 16:36
…n bytes

Two confirmed bugs found via a coverage-guided property/fuzz campaign
against the public HTTP and --persist seams, each with a minimized,
reproducible counterexample and a regression test.

1. Startup crash on a `null` persistence record (src/persist.ts)
   FileStore.loadState() parsed every non-empty line as JSON and replayed
   it unconditionally. A single valid-JSON `null` line reached
   Manager.applyLoadedEvent, which dereferenced `null.enqueue` and threw a
   TypeError that aborted server startup before it could listen.
   Root cause: no event-shape validation at the persistence decode
   boundary. Fix: an isQueueEvent() type guard rejects non-conforming
   records (non-objects, wrong field types, or enqueue==dequeue) so they
   are skipped like malformed JSON. Regression test starts a real
   main.ts --persist process against a `persist.dat` containing `null`
   and asserts /health and /queues still respond.

2. Multi-byte UTF-8 body evades the 1 MiB size limit (src/handler.ts)
   readRequestBody's backstop checked `body.length` after
   `request.text()`, which counts UTF-16 code units, not wire bytes. A
   chunked (no Content-Length) UTF-8 body of 1.2 MiB bytes but 400K code
   units bypassed the byte limit and was enqueued with 200 instead of
   413. String bodies were unaffected because Deno sets Content-Length
   to the byte length, so only chunked requests evaded the check.
   Root cause: the limit was measured in code units, not bytes. Fix:
   re-measure via LOG_ENCODER.encode(body).length (bytes), reusing the
   existing module-level TextEncoder so no new coupling is introduced.
   Regression test sends a streaming multi-byte body with no
   Content-Length and asserts 413.

Diagnosis per /diagnosing-bugs: tight red-capable loop -> minimized
repro -> ranked falsifiable hypotheses -> confirmed root cause -> fix
+ regression test -> original repro no longer reproduces.

Verified locally against all CI jobs:
- deno test --allow-read --allow-write --allow-net --allow-env --allow-run: 374 passed
- mutasaurus: 99% overall, all files >=80% (handler 100%, persist 100%)
- stryker: all files >=80% (handler 89%, persist 84%), stryker_check exit 0
- production quality gate: exit 0, no new warnings
- deno compile main.ts: exit 0

Co-Authored-By: Claude <noreply@anthropic.com>
CI Stryker dropped persist.ts to 72% (35/127 survived) while local ran
at 84%: the 15-mutant gap concentrated in the new isQueueEvent() guard,
whose branches were only exercised indirectly through slow,
timing-dependent tests (e2e server startup, file-lock subprocess). Under
Stryker's concurrency-2 load on CI those kills became unreliable.

Export isQueueEvent and add pure, I/O-free unit tests covering every
guard branch (valid events accepted; null/array/primitive/non-string
queue/missing payload/non-boolean flags/enqueue==dequeue rejected), plus
a loadState test that mixes invalid and valid records. These kill the
guard mutants deterministically on both local and CI.

Expected to bring persist.ts to ~84% on CI (107/127), above the 80% gate.

Co-Authored-By: Claude <noreply@anthropic.com>
@jonbaldie
jonbaldie merged commit 306a9a2 into main Aug 27, 2026
4 checks passed
@jonbaldie
jonbaldie deleted the agent/fix-persist-null-and-utf8-body-limit branch August 27, 2026 17:22
jonbaldie added a commit that referenced this pull request Aug 27, 2026
Merge origin/main (PR #62) into worktree-seeking-performance:
- Adopt isQueueEvent<T>() validation in stream-parsing loadState
- Add close() to non-event JSON test to fix file handle leak

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant