Skip to content

Bug-hunt round 32: unmergeable-once-wrapped capture records, dual-recorder misdiagnosis - #48

Merged
REPPL merged 5 commits into
mainfrom
bughunt-32
Aug 7, 2026
Merged

Bug-hunt round 32: unmergeable-once-wrapped capture records, dual-recorder misdiagnosis#48
REPPL merged 5 commits into
mainfrom
bughunt-32

Conversation

@REPPL

@REPPL REPPL commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Round 32 of the autonomous bug-hunt loop (state tracked on #24).

Confirmed findings (substantive, 2)

1. A captured record could pass its own JSONL line-length check and still be permanently unreadable once merge wraps it.

internal/demo/demo.go's POST /api/interactions handler and internal/transcribe/transcribe.go's writer each checked only a record's own encoded size against the 4 MiB session.MaxJSONLLine limit. But merge re-frames every accepted record into a timeline entry (internal/timeline/timeline.go's BuildEntries: a src/id/payload envelope, plus the time field rebased from epoch-ms to relative seconds) before session.WriteJSONL checks that line against the same limit. The two checks measured different byte strings against the same bound, so a record within the raw line limit could still wrap into an entry over it.

Reproduced end-to-end: a 4,194,303-byte interaction posted to a live demo server got 204 and was durably persisted to interactions.jsonl; the following merge failed with line 1 of the output encodes to 4194334 bytes, over the 4194304-byte JSONL line limit and kept failing on every re-run — report and analyze then failed too (timeline.jsonl never exists), with no CLI-level repair. The same gap exists on the speech side: transcribe could write a transcript.jsonl its own merge immediately refuses.

A second, independent contributor made the window far wider than the envelope bytes alone: session.WriteJSONL's encoder HTML-escaped <, >, and & into six-byte \uXXXX sequences by default, while compactLine (the capture-side line canonicaliser) does not escape at all. A record whose content happened to carry many of those three characters could see its wrapped entry inflate up to sixfold — pulling the practical failure window down from a ~30–40 byte sliver at the very top of the raw range to any record from roughly 700 KiB upward.

Fix: new timeline.EventEntry/timeline.SpeechEntry build the exact entry a record becomes, and new session.EncodedLen measures a value exactly as WriteJSONL will. Both capture paths now check that measurement, not just the record's own size, before accepting it. WriteJSONL's two encoders (the pre-flight check pass and the final writer) both now set SetEscapeHTML(false), matching compactLine's existing non-escaping behaviour and closing the escaping-amplification gap at its root.

Reachability: the bundled demo page truncates captured text/value to 40/80 characters, so it cannot trigger this on its own — it needs a custom instrumented client posting near the documented 4 MiB cap (a supported, documented integration path — see docs/how-to/instrument-your-own-app.md), or a hand-edited/exchanged session. Both refuters independently confirmed the mechanism, reproduced it end-to-end, and found it slightly worse than initially reported (the escaping amplification); see .abcd/work/DECISIONS.md's round 32 entry.

2. record -video misdiagnosed a second recorder that also exited on its own.

When a recorder exits before record asks it to stop, internal/record/record.go's Run excludes that one child (if c != dead) from the missing-output sweep, because its own exit disproves classifyMissingOutput's "stayed blocked on the permission prompt" narrative. The exclusion covered exactly one child. When a second recorder also exits on its own at the same moment — e.g. a TCC-denied microphone alongside a screen recorder whose ffmpeg build lacks a needed codec — it still fell through to classifyMissingOutput, reporting a recorder that had demonstrably exited as still blocked on a permission prompt, with its real exit status never surfaced at all.

Both refuters reproduced this deterministically (both recorders' done channels closed before Run was even invoked, removing the scheduling race): one run printed "screen capture failed to start — check the ffmpeg output" for the recorder anyExit's select picked, and, in the same run, "the recorder stayed blocked on the permission prompt" for the other recorder — which had also already exited.

Fix: Run now samples every child whose done channel is already closed the instant the exit is observed — before stopAll's SIGINT reaches the others, after which a live recorder's own clean shutdown becomes indistinguishable from a self-exit. Every early-exited child is excluded from the sweep and gets its own classifyRecorderExit diagnosis printed to the operator's log; the single child anyExit's select happened to pick remains the command's one returned error, as before.

Considered and rejected (2 nitpicks, both refuted)

  • .github/workflows/ci.yml's check-job comment (not the file header) omitting the gofmt, ldflags, and installer steps from its own prose. Refuted by both reviewers: gofmt was already present in the job when the comment was first written (so its omission was never staleness), and the comment was rewritten in round 15 — after the installer steps already existed — without naming them either. Read in context, it is a rationale note about why the job is Ubuntu-only and single-named, not an enumeration; the file header ten lines above, which is an enumeration, was already fixed for exactly this reason in round 26.
  • .github/workflows/release.yml's header omitting the install-e2e smoke test, the pre/post-publish version asserts, and the no-branch-commit tripwire. Refuted by both reviewers: the header states the tripwire as an explicit guarantee ("nothing is pushed to any branch"), the post-publish attestation verify — which the finding didn't even name — is equally unnamed and equally present since the file's first commit, and a "fix" would mean duplicating the fuller per-step comments already present ~200 lines below.

Not re-raised: docs/reference/session-directory.md's words row omission-cause gap, already adjudicated and discarded on a split refuter verdict in round 31.

Verification

  • gofmt -l ., go build, go vet ./..., go test ./..., go test -race ./... all pass.
  • Pipeline smoke (merge/report on examples/sample-session) passes.
  • Every fix carries a test that failed before the change and passes after: TestOversizedInteractionIsRefusedNotPersisted's new subtest and TestWriteJSONLDoesNotEscapeHTML/TestEncodedLenMatchesWriteJSONL for finding 1's demo/session halves, TestCheckEntriesFitRefusesUnmergeableEntry for its transcribe half, and TestEarlyRecorderExitDoesNotDoubleDiagnoseWithTwoRecorders for finding 2.
  • docs/reference/cli.md and docs/how-to/instrument-your-own-app.md updated to describe the new, more precise 413 condition.

Adversarial review outcome

Two independent post-hoc reviews of the full diff (correctness; docs accuracy) both returned BLOCK on the same defect, found independently: the second early-exited recorder's atStartup classification (added for finding 2) was sampled after stopAll/stopDemo ran, reopening the exact "slow stop poisons the start-up classification" bug dead's own atStartup sampling — and TestRunClassifiesStartupExitDespiteSlowStop — already guard against. A start-up TCC denial on the non-dead recorder could be reported as an unexpected mid-session device fault whenever the demo shutdown or the other child's stop grace ran long.

Fixed in a follow-up commit: atStartup is now sampled for every early-exited child in the same pre-stopAll loop that detects them, with a new regression test (TestEarlyRecorderExitTwoRecordersClassifiesStartupExitDespiteSlowDemoStop) that fails before the fix and passes after. The reviews' non-blocking findings (a garbled eventIDGrowthMargin comment, "permanently unreadable" overstating finding 1's failure mechanism, and the docs' envelope description naming the one component that doesn't cause the overflow) are folded into the same commit. CI is green on the fixed commit.

Per the loop's merge gate, a BLOCK from either reviewer means this PR stays open for a human to merge rather than auto-merging, even though the raised issue is now fixed and re-verified — this round did not re-run the adversarial reviews after the fix.


Assisted-by: Claude:claude-sonnet-5

REPPL added 5 commits August 6, 2026 23:51
… record

POST /api/interactions and transcribe checked only a captured record's own
JSONL line length against the 4 MiB limit. merge re-frames each record into
a larger timeline entry (a src/id/payload envelope, timeline.BuildEntries)
that session.WriteJSONL checks again at merge time — so a record within the
raw line limit could still wrap into an entry over it, get durably persisted
at 204/exit 0, and then be permanently unreadable by merge, report, and
analyze, with no CLI-level repair.

WriteJSONL's encoder also HTML-escaped <, >, and & into six-byte \uXXXX
sequences while compactLine (the capture-side line canonicaliser) does not,
so escaping alone could inflate a record's wrapped entry up to sixfold —
pulling the failure window from a ~30-byte sliver at the very top of the
range down to any record from roughly 700 KiB up.

New timeline.EventEntry/SpeechEntry build the entry a record becomes, and
session.EncodedLen measures it exactly as WriteJSONL will; both capture
paths check that measurement before accepting a record. WriteJSONL's own
encoders no longer escape HTML, matching compactLine's existing behaviour.

Assisted-by: Claude:claude-sonnet-5
When a recorder exited on its own, Run excluded only that single child
(if c != dead) from the missing-output sweep, on the reasoning that its
own exit already disproves classifyMissingOutput's stayed-blocked-on-
the-prompt narrative. That exclusion covered exactly one child: if a
second recorder also exited on its own at the same moment — a TCC-denied
microphone alongside a screen recorder whose ffmpeg build lacks a needed
codec, say — it still fell through to classifyMissingOutput, reporting a
recorder that had demonstrably exited as still blocked on a permission
prompt, with its real exit status never surfaced.

Run now samples every child whose done channel is already closed the
moment the exit is observed, before stopAll's SIGINT reaches the others
(after which a live recorder's clean shutdown becomes indistinguishable
from a self-exit). Every such child is excluded from the sweep and gets
its own classifyRecorderExit diagnosis instead.

Assisted-by: Claude:claude-sonnet-5
Adversarial review of the round's own PR caught a regression the fix for
finding 2 introduced: the sibling early-exited child's atStartup was
sampled after stopAll/stopDemo ran, reopening the exact "slow stop
poisons the start-up classification" bug dead's own atStartup is
deliberately sampled before stopAll to avoid (see the comment and
TestRunClassifiesStartupExitDespiteSlowStop). A start-up TCC denial on
the non-dead recorder was reported as an unexpected mid-session device
fault whenever the demo shutdown or the other child's stop grace ran
long. Both reviewers reproduced it independently.

Fixed by sampling atStartup for every early-exited child in the same
pre-stopAll loop that detects them, alongside a new regression test.

Also folds in the reviews' non-blocking findings: a garbled
eventIDGrowthMargin rationale comment, "permanently unreadable"
overstating the failure mechanism (merge refuses to *write* the
timeline entry; report/analyze never see one, they don't "refuse" it),
and the docs' "internal id and time-scale envelope" gloss naming the
one component that doesn't cause the overflow, replaced with an
accurate description linking the documented timeline.jsonl schema.

Assisted-by: Claude:claude-sonnet-5
@REPPL REPPL mentioned this pull request Aug 7, 2026
@REPPL
REPPL merged commit 745c080 into main Aug 7, 2026
6 checks passed
@REPPL
REPPL deleted the bughunt-32 branch August 7, 2026 11:36
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