REQ-video-ring-buffer flipped from [x] back to [ ]. ROADMAP.md Phase 1
row reverted from [x] Closed 2026-05-15 to [ ] reopened 2026-05-16.
STATE.md status flipped phase_complete → phase_reopened with full
historical narrative preserved.
Root cause (confirmed at byte level by gsd-debugger 2026-05-16):
D-13's concat-of-self-contained-WebM-segments architecture produces a
3-EBML-header WebM that standards-compliant Matroska parsers
(mpv, ffmpeg, Chrome HTMLMediaElement) play only as the first segment
(~9.94 s) and silently drop the remaining 2 segments. Confirmed via
operator mpv drag-drop test of BOTH the canonical 2026-05-15 closure
fixture and the 2026-05-16 UAT-produced fixture — both exhibit the
same broken playback.
The 2026-05-15 "operator-confirmed clean Chrome playback" assessment
was insufficient: it verified the file plays without freezing but did
not measure total duration. Phase 1's primary deliverable
(REQ-video-ring-buffer / SPEC §10 #7) is therefore NOT satisfied.
Fix path chosen by user: ts-ebml (parse) + webm-muxer (write) to
replace mergeVideoSegments file-concat with real single-EBML remux.
Will land as Plan 01-08 via fresh /gsd-plan-phase ceremony.
RED test landed in tests/offscreen/webm-playback.test.ts (2 new
assertions on container-format-duration + ffmpeg-full-decode-duration).
2 failures, 53 baseline tests still GREEN.
Option C port-lifecycle refactor (debug session
empty-archive-port-race, commits 674c415..f0871c0) DID land cleanly
and is retained — that fix was orthogonal and correctly resolved the
silent-empty-archive symptom that previously masked this deeper bug.
Debug session: .planning/debug/d13-multi-ebml-concat-unplayable.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the offscreen-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":
1. **Retired** the 290_000 ms pre-emptive reconnect setTimeout. Its race
window between the synchronous .disconnect() and the onDisconnect
handler running was the bisect-confirmed proximate cause of the H1
"Attempting to use a disconnected port object" Uncaught Errors.
2. **Added** PONG-based health probe: each ping increments missedPongs;
if MAX_MISSED_PONGS (3) consecutive PINGs go without echo, reconnect
via the same clean teardown path the onDisconnect handler uses.
PONG receipt resets the counter. Liveness-based replacement for the
time-based pre-emptive rotation.
3. **H1 fix** — wrap PING postMessage in try/catch. The port object can
transition to disconnected synchronously (SW eviction, port glitch)
between the interval-callback being queued and it running. The catch
absorbs the throw and routes through reconnectPort() — no more
uncaught throws bubble out to the offscreen console.
4. **Request-id'd protocol** — REQUEST_BUFFER carries the SW-generated
requestId; BUFFER response echoes it. The offscreen now posts on the
CURRENT keepalivePort (no more portAtRequest stale-port refuse-to-
post). The SW matches BUFFER → request by id, so port replacement
mid-encode no longer drops the response — the SW retries on the new
port and the matching BUFFER routes correctly.
5. **reconnectPort(reason)** — new helper consolidating the
teardown+disconnect+reconnect dance used by both the missed-PONG
path and the synchronous-throw path. Idempotent w.r.t. the chained
onDisconnect callback.
Test updates:
- H2 now sends REQUEST_BUFFER with a requestId (Option C contract).
- H1.b refactored to test the externally-disconnected path (since the
pre-emptive timeout path is gone): port._disconnected=true, fire
ping, assert no throw + a fresh port appears.
- Top-level snapshots of timer globals + afterEach restoration so a
failing test doesn't leak overridden globals into the next test.
Status: 48 GREEN, 4 RED (the remaining RED is all SW-side — addressed
in next commit). All H1 + H1.b + H2 contracts now GREEN. Pinning
contracts (D-12 port-serialization, D-13 segment-rotation, A3 webm-
playback) untouched. tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C
(Architectural)", land RED tests that pin the 4 sub-behaviours the
refactor must satisfy at the unit level. These complement the operator-
facing contract already pinned by port-reconnect-race.test.ts (H1+H2).
Offscreen side (tests/offscreen/port-health-probe.test.ts):
A. Bootstrap installs no 290_000 ms pre-emptive reconnect timer
(the timing-based race window from b064a21 is gone).
B. Missed PONGs (5 PINGs without echo) trigger a clean reconnect via
the same path the onDisconnect handler uses.
C. PONG echoes within timeout keep the port alive indefinitely
(counter-test for over-eager probe — already GREEN today).
D. REQUEST_BUFFER with requestId → BUFFER response echoes the same id
(the architectural mechanism that retires cross-talk).
SW side (tests/background/request-id-protocol.test.ts):
1. getVideoBufferFromOffscreen sends REQUEST_BUFFER with a generated
uuid requestId on the live videoPort.
2. Stale BUFFER (mismatched requestId) is ignored — no resolution.
3. Port replacement mid-request → SW re-issues REQUEST_BUFFER on the
new port with the SAME requestId. Retires the H2 silent-drop class.
4. Empty video segments → saveArchive returns {success:false, error}
(operator-visible) instead of {success:true} with no-video archive.
5. SW echoes PONG on PING, closing the health-probe loop.
Suite status: 10 files / 52 tests (42 GREEN, 10 RED).
- 40 baseline + 2 new GREEN (port-health-probe C; request-id 2 & 4
accidentally pass due to test-stub side effects — they will continue
to pass after fix for the right reasons).
- 3 RED in port-reconnect-race + 3 RED in port-health-probe + 4 RED
in request-id-protocol.
Quality gates: tsc --noEmit exit 0; type-safety grep clean.
No production code touched in this commit — fix lands in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 UAT Test 3 surfaced a two-headed BLOCKER:
(a) silent empty-video archive when save crosses a port-reconnect window,
(b) 3x "Attempting to use a disconnected port object" Uncaught Errors
starting at the 290 s pre-emptive reconnect mark.
Bisect confirmed: H1 (port lifecycle race) was introduced by Plan 01-04
(b064a21); H2 (createArchive silent-skip on empty segments) is an upstream
defect (555eb05) that became fatal once CR-01 + sweep #5 guaranteed the
silent-skip branch would fire on every save during a reconnect window.
This commit lands the 3 RED tests at the unit-test level — they match the
UAT error string byte-for-byte for H1/H1.b and pin the silent-drop
contract for H2. They will flip GREEN as the Option C architectural
refactor (request-id'd port protocol + port-health probe + retry +
operator-visible error surface) lands across the next commits.
Baseline: 8 files / 43 tests (40 GREEN, 3 RED). tsc --noEmit exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the RED contracts that the webm-playback-freeze debug
session landed (before this fix-a3 cycle started) plus the original
Plan 07 smoke fixture they run against. None of these files were
modified by this fix cycle — they are landed as-is from the debug
session to make the test history bisectable.
Files staged:
- tests/offscreen/segment-keyframes.test.ts
Three describe blocks (~340 LOC):
* documentation — pure-simulation tests that pin the D-09..D-11
failure mode as executable evidence (regression guard against
re-introducing single-continuous-recorder semantics)
* GREEN-pinning — pure-simulation tests that pin the D-13
segment-keyframe invariant
* production-driven — imports src/offscreen/recorder.ts and
asserts (i) `getSegments` exported as a function, (ii) it
returns at most 3 Blobs. THIS BLOCK IS NOW GREEN after the
D-13 activation in the prior commits — was the genuine TDD
anchor for fix-a3.
- tests/offscreen/webm-playback.test.ts
Two empirical-ffmpeg assertions on tests/fixtures/last_30sec.webm:
* zero "Error submitting packet to decoder" lines from the
VP9 decoder
* no "File ended prematurely" container-finalization error
Both STAY RED in this commit because the committed fixture is
still the stale one from Plan 07's pre-fix smoke. They flip
GREEN after the operator runs ./smoke.sh to regenerate the
fixture against the D-13 recorder — see the closing message
and the NEXT-STEP block of the resolved debug session.
- tests/fixtures/last_30sec.webm
The 2.1 MB Plan 07 smoke artifact. Committed deliberately so
the empirical RED test has something to run against. Will be
overwritten by the next ./smoke.sh run (single-file rotation —
the path is fixed by the smoke script + zip extraction step
in the debug-session reproduction).
Verification:
- npx vitest run --reporter=dot → Tests 2 failed | 28 passed (30)
- The 2 fails are EXACTLY the two empirical-ffmpeg assertions in
webm-playback.test.ts; the structural production-driven block
in segment-keyframes.test.ts is fully GREEN.
- npx tsc --noEmit clean.
- npm run build succeeds.
Operator action required before Phase 1 close (Plan 07 still owns
REQ-video-ring-buffer): re-run ./smoke.sh per the documented
6-step reproduction in the debug session, then re-run
`npx vitest run tests/offscreen/webm-playback.test.ts` and
expect both assertions to flip GREEN. Plan 07 success criterion
§10 #7 (playback) lands at that point.
Per debug session webm-playback-freeze "Activation Plan" step 4: the
D-09..D-11 ring-buffer semantics (first-chunk header pin + 30 s age trim)
are being replaced by D-13 restart-segments. The pinned-header assertions
were architecture-specific and become meaningless once each segment is
a self-contained WebM with its own EBML header and seed keyframe.
Changes:
- tests/offscreen/ring-buffer.test.ts: collapsed to a single breadcrumb
test pointing at the successor file. Kept the path so git history /
failure bisects land on the retirement commit cleanly.
- tests/offscreen/segment-rotation.test.ts (new): 8 tests pinning the
D-13 invariants against the production recorder module:
* MAX_SEGMENTS = 3, SEGMENT_DURATION_MS = 10_000 (= legacy 30 s window)
* empty-by-default, ordered, oldest-evicted-at-cap
* resetBuffer clears
* getSegments returns a defensive snapshot (no internal aliasing)
Uses a `pushSegmentForTest` seam so vitest can drive rotation
deterministically without instantiating a real MediaRecorder.
RED today by design (TDD discipline) — the segment-rotation suite
imports `getSegments`, `pushSegmentForTest`, `MAX_SEGMENTS`,
`SEGMENT_DURATION_MS` from src/offscreen/recorder.ts. Those exports
land in the next commit. tsconfig.include is "src" only so tsc stays
clean during the RED window.
- Add blobToBase64 / base64ToBlob in src/shared/binary.ts:
portable Blob↔base64 round-trip for the chrome.runtime.Port
wire-format. JSON.stringify(blob) returns "{}" across extension
contexts, so binary payloads must travel as base64 strings.
- Mirror the GREEN-block helper signatures from
tests/offscreen/port-serialization.test.ts so the same test pins
both the standalone helpers and the production wire format.
- Land tests/offscreen/port-serialization.test.ts as the RED+GREEN
executable contract for the D-12 fix: the RED block reproduces
the 75-byte "[object Object]" failure mode byte-for-byte; the
GREEN block pins the base64 wire-format the fix must implement.
- Uses arrayBuffer() + btoa(String.fromCharCode...) rather than
FileReader: FileReader is browser-only; the chosen approach
works in both Chrome extension contexts and the Node-based
vitest environment.
Refs: debug session d12-blob-port-transfer-fails.
Three RED tests pin Pattern 4 (handshake) and Pattern 5 / Pitfall 4
(port reconnect on disconnect) contracts:
handshake.test.ts:
- 'sends OFFSCREEN_READY after listener registration' — exactly one
OFFSCREEN_READY emitted at module load, AFTER onMessage.addListener
port.test.ts:
- 'connects on module load' — chrome.runtime.connect called once
- 'reconnects when port disconnects' — firing onDisconnect triggers
immediate re-connect (Pitfall 4 idle-timer reset)
chrome.runtime is stubbed locally (no vitest-chrome dependency added).
No 'as any' / no '@ts-ignore'; casts are 'as unknown as T'.
Plan 04 must wire OFFSCREEN_READY send + port.connect({ name:
'video-keepalive' }) + onDisconnect-driven reconnect at the import-side
effect layer of src/offscreen/recorder.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two RED tests pin D-20 (codec strict-mode, no silent fallback):
- 'throws on unsupported vp9 and emits RECORDING_ERROR'
- 'does not throw when vp9 IS supported'
vi.resetModules() between tests is critical: module-load side-effects
(handshake + port connect) happen once per import, so isolation across
the four test files depends on it.
chrome.runtime is stubbed locally (no vitest-chrome dependency added,
per threat T-1-NEW-02-01 — minimize supply chain for four test files).
No 'as any' / no '@ts-ignore'; the cast is 'as unknown as T'.
Plan 03 must export assertCodecSupported() from src/offscreen/recorder.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four RED tests pin D-10 (header pinning) and D-11 (30s trim) contracts:
- 'first chunk is header' — isFirst marker on first addChunk
- 'second chunk is NOT header' — only the first is pinned
- 'trim 30s — keeps header, evicts aged tail' — header survives indefinitely
- 'trim with empty buffer does not throw' — defensive edge case
Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer} from
src/offscreen/recorder.ts to flip these to GREEN.
Also stages tests/fixtures/.gitkeep so the fixture dir survives clean
checkouts (Plan 07 drops a known-good last_30sec.webm into it after the
manual smoke test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>