- src/background/index.ts now imports remuxSegments from './webm-remux'
and awaits it in createArchive instead of synchronously calling the
retired file-concat mergeVideoSegments.
- mergeVideoSegments function declaration deleted entirely; only a
retirement comment remains naming Plan 01-08 D-14-remux as the
superseding decision.
- EmptyVideoBufferError throw paths preserved on (a) zero segments
AND (b) zero-byte output. Error message free-text changed from
"merged video blob is zero bytes" to "remuxed video blob is zero
bytes"; pre-flight grep (W-01 fix from plan checker pass)
confirmed no downstream consumer matches on the legacy string —
request-id-protocol.test.ts asserts on error.code ('empty-video-
buffer'), not the free-text message.
- createArchive remains async (was already declared async); saveArchive
already awaits createArchive so no upstream signature changes.
- Stale comment in decodeBufferSegments referencing mergeVideoSegments
updated to reflect the new remux pipeline (Rule 3: keep forward-
references accurate).
- CONTEXT.md amendment provenance verified intact via 4 grep checks
(B-01 fix from plan checker, folded from retired Task 6):
(a) D-14-remux disambiguated marker present (1 match)
(b) original D-13 line preserved (1 match)
(c) D-17-port-lifecycle amendment intact (1 match)
(d) webm-remux.ts replaces citation present (1 match)
No CONTEXT.md mutation by this task — verify-only step.
- npm run build exit 0; main SW bundle 374.56 KB (108.44 KB gzipped,
matches the d13 library survey's ~100 KB estimate for ts-ebml +
webm-muxer combined).
- Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration
assertions waiting on Task 5 fixture regen). tsc exit 0.
Drives all 5 RED tests in tests/background/webm-remux.test.ts to GREEN.
- Parses each VideoSegment via ts-ebml Decoder; tracks current Cluster
Timestamp; extracts each SimpleBlock's VP9 frame(s) + keyframe flag
+ segment-local timestamp via tools.ebmlBlock.
- Re-emits all frames through a single webm-muxer Muxer<ArrayBufferTarget>
configured with type:'webm', codec:'V_VP9', and adjusted monotonic
timestamps (segmentBaseMs + cluster.Timestamp + block.timecode,
microseconds for the muxer).
- Picks track info (PixelWidth, PixelHeight, optional CodecPrivate)
from first segment that exposes them; falls back to 1024x768 with
a logged warning per Task 5's failure-mode (e).
- Defensive: empty input -> empty Blob (Test 5); sort by timestamp
ascending (mirrors retired mergeVideoSegments order discipline).
- 434 LOC including extensive JSDoc per project style; 8 small named
helpers, no nested mega-functions.
- Empirically: 3-segment fixture -> 912 frames in 29.954 s,
1_643_057 bytes (single-EBML); ffprobe duration=29.94s, count_frames=912.
- Logging via new Logger('Remux'); no console.* anywhere; no as any;
no @ts-ignore.
Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions
still failing against the stale fixture — Task 4 swaps the call site,
Task 5 regenerates the fixture). tsc exit 0.
Retires the upstream silent-skip defect (bisected to commit 555eb05 —
imported broken from before Phase 1, never on the original 22-defect
audit because it needed a real failure mode to surface). Per
.planning/debug/empty-archive-port-race.md Option C step 4: even with
the architectural port lifecycle now bullet-proof, a hard outer timeout
on the BUFFER fetch (10 s after every retry) must result in an
operator-VISIBLE failure — not a silent video-less archive.
1. **EmptyVideoBufferError** — typed error class with a stable `code`
field ('empty-video-buffer') matching the offscreen-side
CaptureErrorCode union vocabulary. Lets saveArchive's catch
distinguish "no segments" failure from JSZip/manifest failures.
2. **createArchive throws** — when videoBufferResponse.segments.length
=== 0 OR when the merged blob is zero bytes, throw the typed error
with a detail string for diagnostics. Replaces the silent-skip
branch that was the bisect-confirmed transport for the H2 class.
3. **saveArchive broadcast** — on EmptyVideoBufferError, emit
{type:'RECORDING_ERROR', error:'empty-video-buffer'} via
chrome.runtime.sendMessage. The popup's existing RECORDING_ERROR
handler surfaces the failure to the operator (same channel as
codec-unsupported, user-cancelled, etc.). saveArchive still returns
{success:false, error} so the popup's direct-response path also
sees the failure (defense-in-depth via two channels).
Status: 52 GREEN / 52 tests passing. All 12 RED tests from the Option C
gate (3 in port-reconnect-race + 4 in port-health-probe + 5 in
request-id-protocol) are now GREEN. Build clean (npm run build exit 0).
Pinning contracts intact:
- D-12 port-serialization (base64 wire format): GREEN
- D-13 segment-rotation (3 x 10 s restart-segments ring): GREEN
- A3 webm-playback (ffmpeg dry-run on fixture): GREEN
tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the SW-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":
1. **Request-id'd protocol** — getVideoBufferFromOffscreen generates a
uuid (crypto.randomUUID with Math.random fallback) and sends
{type:'REQUEST_BUFFER', requestId} on the live videoPort. The
per-request listener pattern is GONE; replaced by a module-level
pendingBufferRequests Map<requestId, PendingBufferRequest>. The
onConnect-level message sink routes BUFFER -> resolve by id.
2. **Stale BUFFER routing** — BUFFER messages without a matching
requestId in the Map are silently dropped (no cross-talk). BUFFER
without a valid requestId at all is rejected with a warn (Option C
protocol requires the id).
3. **Retry on port replacement** — every onConnect (post-bootstrap)
scans pendingBufferRequests and re-issues REQUEST_BUFFER on the
fresh port with the SAME requestId. The offscreen posts BUFFER on
the current keepalivePort (see prior offscreen commit), the sink
matches by id, and the request resolves. This retires the H2
silent-drop class architecturally — the BUFFER reaches the SW
regardless of port-replacement timing.
4. **PING -> PONG echo** — the sink replies to every PING with PONG.
Closes the offscreen's health-probe loop (it counts missed PONGs
and reconnects when MAX_MISSED_PONGS exceeded — see prior offscreen
commit). The PONG post is wrapped in try/catch to absorb the same
port-closed-mid-response race the offscreen ping path handles.
5. **Outer hard-timeout bumped 2s -> 10s** — the legacy per-port
BUFFER_FETCH_TIMEOUT_MS = 2000 was too tight to retry across a
reconnect. The new outer budget covers EVERY retry across port
replacements; the inner round-trip is still ~100-200 ms.
6. **decodeBufferSegments extracted** — pulled out of the legacy
inline handler so the new onConnect sink can decode wire segments
without duplicating the logic. Preserves WR-07 (empty wire segment
filter) and base64ToBlob defensive catch behaviour. Closes the
pre-existing implicit-undefined-return path the legacy flatMap
catch had (tsc happy but semantically ambiguous).
Status: 51 GREEN, 1 RED. The remaining RED (createArchive must throw
on empty video, surfacing to operator) is addressed in the next commit.
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>
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>
Pure type-extension step for the Option C architectural refactor of the
offscreen↔SW port lifecycle (.planning/debug/empty-archive-port-race.md).
PortMessage.requestId is optional so PING/PONG keep their no-payload
shape. REQUEST_BUFFER and BUFFER will populate it once the recorder
(offscreen) and getVideoBufferFromOffscreen (SW) are wired up in the
next commits. The narrowing remains structural — every consumer must
still validate `type` before accessing `requestId`.
PONG joins PING as a no-payload liveness signal. The SW echoes PONG on
PING, closing the health-probe loop the offscreen uses to detect a dead
port (replacing the 290 s pre-emptive setTimeout that was the proximate
cause of the H1 race window per the bisect).
No runtime change. 52 tests, 10 RED still RED (waiting for impl).
tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What was wrong:
- CR-01 (recorder.ts): encodeAndSendBuffer captured no port identity before
awaiting Promise.all(blobToBase64). If the port disconnected mid-encode
and onDisconnect synchronously reconnected (re-assigning keepalivePort
to a fresh instance), the post-await null-check evaluated false and
the BUFFER was posted on the NEW port — but the SW's per-request
onMessage listener was still bound to the OLD port (captured at
getVideoBufferFromOffscreen line 110). Result: SW timed out after
2 s, SAVE_ARCHIVE produced an empty-segments zip, data-loss path
masquerading as a benign timeout.
- CR-02 (background/index.ts): SW's onConnect handler attached
ONLY onDisconnect — no permanent onMessage sink. PING traffic
had no listener when getVideoBufferFromOffscreen wasn't running
(the normal idle state of the port), and field reports note Chrome's
SW idle-timer reset behaves inconsistently when no listener is
attached. Risk: PINGs silently dropped, SW evicted ~30 s into
recording, port torn down, next SAVE_ARCHIVE fails entirely.
- CR-03 (background/index.ts): offscreenReady is a one-shot Promise
resolved on the FIRST OFFSCREEN_READY message. If the SW is evicted
while the offscreen document persists, the next SW lifetime creates
a fresh Promise and waits on it forever — the offscreen never
re-emits OFFSCREEN_READY. startVideoCapture() hangs at
`await offscreenReady` until Chrome restarts.
- WR-03 (recorder.ts): `baseTimestamp + idx` (Date.now() + idx) used
millisecond resolution + array offset. Two REQUEST_BUFFER calls
within the same millisecond would collide, breaking the sort-by-
timestamp contract in SW-side mergeVideoSegments.
- WR-09 (recorder.ts): encodeAndSendBuffer always appended the
unfinalized in-flight segment to the BUFFER. That segment lacks
the Matroska SegmentSize and Cues that MediaRecorder.stop()
writes — re-introducing the "File ended prematurely" symptom
documented in debug session webm-playback-freeze.
What changed:
- recorder.ts encodeAndSendBuffer:
- Capture `portAtRequest = keepalivePort` BEFORE the encode.
- After the await, refuse to post if `keepalivePort !== portAtRequest`
(port was replaced by reconnect). SW already times out cleanly
after BUFFER_FETCH_TIMEOUT_MS = 2 s; the next SAVE_ARCHIVE
re-issues REQUEST_BUFFER on the fresh port. Stale data
NEVER reaches a stranger port.
- Include the in-flight segment ONLY when finalized.length === 0
(preserve the SAVE-within-first-10-s UX trade-off documented at
the original comment) — otherwise drop the unfinalized tail.
- Replace `baseTimestamp + idx` with module-level monotonic
`++segmentSeq` counter (zero wall-clock dependency).
- Switch from Promise.all/map+filter to a sequential for-loop
because each iteration now mutates the shared `segmentSeq`;
Promise.all timing would interleave assignments. Throughput
impact negligible (3 segments × ~50 ms base64 each ≈ 150 ms
vs ~50 ms parallel — still well under the 2 s SW budget).
- background/index.ts onConnect:
- Install a permanent `port.onMessage.addListener` that
explicitly drains PING and silently drops unknown traffic.
Per-request BUFFER listener still wins because it's attached
LATER in the listener chain when getVideoBufferFromOffscreen
fires; this sink only catches the idle PING stream and
guarantees the SW idle-timer reset is consumed by a real
handler.
- background/index.ts initialize():
- When `chrome.offscreen.hasDocument()` returns true on SW init,
immediately resolve `offscreenReady` AND null
`offscreenReadyResolve`. The offscreen MUST have completed its
bootstrap before it was observable via hasDocument(); waiting
for an OFFSCREEN_READY that will never come is a deadlock.
Why these fixes vs alternatives:
- CR-01: alternatives considered: (a) cancel encoding when port
disconnects (requires AbortController plumbing into blobToBase64);
(b) re-route the BUFFER through the new port via a per-port
request-id correlation. Both add machinery for a case the SW
already handles correctly (2 s timeout → retry). The capture-
identity check is the minimum-mechanism fix and matches REVIEW.md
CR-01 fix guidance exactly.
- CR-02: alternative considered: documenting "rely on kernel-level
port-message side effect for idle-timer reset" — REJECTED, this
is what the existing comment did and the field evidence shows
it's unreliable. Explicit listener is the safe default.
- CR-03: alternative considered: (a) have offscreen re-emit
OFFSCREEN_READY on every inbound SW message — adds noise to
the message bus and races with the original-bootstrap-emit.
Option (b) (resolve-on-hasDocument-true) is simpler, narrower,
and was explicitly recommended by REVIEW.md.
- WR-03: alternative considered: keeping Date.now() and adding a
microsecond-resolution offset via performance.now() — fragile
across SW respawns where performance.now() resets. Module-level
monotonic counter has zero wall-clock dependency.
- WR-09: alternative considered: forcing a synchronous rotation
at REQUEST_BUFFER time. Rejected — adds ~50–200 ms latency to
every save AND races with scheduleRotation()'s timer. The
"exclude unless empty" trade-off matches REVIEW.md option (a)
exactly and preserves the documented first-10-s UX path.
Validation evidence:
- npx tsc --noEmit: exit 0 (no type errors).
- npx vitest run --reporter=dot: 30/30 tests pass in 2.67 s
(8 test files, including port.test.ts which pins the reconnect
invariant and port-serialization.test.ts which pins the wire format).
- grep "as any\|@ts-ignore" src/offscreen/ src/background/index.ts
src/shared/: no matches (type-safety gate stays clean).
Pure rename pass — zero behavioural change at any call site. The
prior "chunk" naming is a vestige of D-09..D-11's chunk-level
buffer; under D-13 the unit of transfer is a self-contained ~10 s
WebM segment, so the type name now matches the data shape.
Renames propagated atomically:
- src/shared/types.ts
* TransferredVideoChunk → TransferredVideoSegment
(also: the `isFirst?: boolean` field is dropped — D-13 segments
are all implicitly their own header, so the flag is meaningless
and only existed for the retired ring-buffer's pin semantics)
* VideoChunk → VideoSegment (drops `isFirst?` for the same reason)
* PortMessage.chunks? → PortMessage.segments?
* VideoBufferResponse.chunks → VideoBufferResponse.segments
- src/offscreen/recorder.ts
* import type rename
* encodeAndSendBuffer's per-element accumulator + filter type
* the port.postMessage payload field
- src/background/index.ts
* import type rename
* getVideoBufferFromOffscreen reads `(msg as {segments?}).segments`
(matching the new wire field name)
* empty-buffer returns `{ segments: [] }`
* mergeVideoSegments signature takes VideoSegment[]
* createArchive consumes videoBufferResponse.segments
* saveArchive log line says "segments"
Verification:
- npx tsc --noEmit clean.
- npx vitest run → 28 passed / 2 failed (the 2 fixture-empirical
ffmpeg dry-runs; unchanged — they're gated on ./smoke.sh regen).
- npm run build succeeds, all 60 modules transformed.
- grep predicates clean in src/:
* no addChunk / trimAged / firstChunkSaved / isFirst
* no TransferredVideoChunk / VideoChunk (old names)
* 21 occurrences of new names propagated correctly
- Pre-existing port-serialization.test.ts still GREEN: its `isFirst`
references are inside inline-test-scoped objects (not imports
from production types), and its tests assert JSON-roundtrip
behaviour rather than the production type shape.
Follow-up to the D-13 activation in src/offscreen/recorder.ts. Each
entry on the BUFFER port message is now a self-contained WebM
segment (not a partial chunk), so the SW-side concat is trivial:
sort by timestamp and Blob-concatenate. The resulting multi-EBML-
header file plays natively in Chrome (SPEC §10 #7 scope).
Changes in src/background/index.ts:
- mergeVideoChunks renamed to mergeVideoSegments; doc comment and
log lines say "segment" throughout. No behavioural change beyond
removing the now-stale per-chunk `isFirst` logging.
- getVideoBufferFromOffscreen no longer reads or carries the
`isFirst` field when decoding wire payload into VideoChunk —
D-13's segment lifecycle makes the flag meaningless (every
segment is a fresh recorder boundary, every segment's first
chunk is implicitly the header). The field stays optional on
VideoChunk for one more commit; commit 4 sweeps the type
rename and drops it.
- The single mergeVideoChunks call site in createArchive updated
to the new name.
Verification:
- npx vitest run → 28 passed / 2 failed (same 2 empirical-ffmpeg
REDs from webm-playback.test.ts; unchanged from prior commit —
the fixture is still the stale single-continuous-recorder one).
- npx tsc --noEmit clean.
- src/background/index.ts now has zero references to addChunk /
trimAged / firstChunkSaved / isFirst.
Replaces the single-continuous-MediaRecorder + age-trim lifecycle
(D-09..D-11, retired) with the restart-segments lifecycle pre-staged
in CONTEXT.md D-13 and RESEARCH.md Pattern 3. Debug session
webm-playback-freeze proved empirically that the prior approach
orphans VP9 P-frame keyframe references when middle chunks are
trimmed — ffmpeg dry-run on last_30sec.webm produced 8 "Error
submitting packet to decoder" lines and the playback froze ~1 s in
Chrome (root-cause-confirmed in the debug session).
Architecture change:
- MediaRecorder is rotated every SEGMENT_DURATION_MS = 10_000 ms on
the SAME mediaStream (so no new getDisplayMedia user gesture).
- Each segment is started fresh, so the encoder seeds an EBML header
+ initial VP9 keyframe — segment is independently decodable.
- Up to MAX_SEGMENTS = 3 finalized segments are retained (3 × 10 s =
30 s, matching the legacy operator-facing window).
- MediaRecorder.start() runs without a timeslice — exactly one
ondataavailable per .stop() yields exactly one Blob per segment.
- onstop → onSegmentStopped finalizes currentChunks, evicts oldest
if over the cap, and immediately starts the next segment.
- ~50–200 ms recording gap at each rotation boundary is the
documented trade-off per CONTEXT.md D-13.
API surface delta:
- REMOVED: addChunk, trimAged, getBuffer, firstChunkSaved
(entire D-09..D-11 ring-buffer module retired)
- ADDED: getSegments, pushSegmentForTest (test seam),
MAX_SEGMENTS, SEGMENT_DURATION_MS exports
- KEPT: assertCodecSupported, resetBuffer (semantics updated to
clear segments + currentChunks + rotation timer),
VIDEO_BUFFER_DURATION_MS (now derived as
MAX_SEGMENTS * SEGMENT_DURATION_MS = 30 s)
- bootstrap(), port lifecycle, OFFSCREEN_READY handshake, T-1-04
sender-id check — all unchanged.
encodeAndSendBuffer iterates segments + the in-flight currentChunks
(if non-empty) so SAVE_ARCHIVE seconds after START_RECORDING still
returns the partial first segment instead of an empty buffer. Each
segment is base64-encoded per the D-12 wire-format contract.
Verification:
- npx vitest run → 28 passed / 2 failed; the 2 failures are exactly
the empirical ffmpeg dry-runs in tests/offscreen/webm-playback.test.ts
which stay RED until the operator regenerates the committed fixture
via ./smoke.sh (expected and documented).
- tests/offscreen/segment-keyframes.test.ts production-driven RED
block is now GREEN — getSegments exists and meets the cap contract.
- tests/offscreen/segment-rotation.test.ts (8 tests, added in the
prior commit) all GREEN.
- npx tsc --noEmit clean.
- Zero new `as any` / `@ts-ignore` introduced.
- Read incoming port.chunks as TransferredVideoChunk[] (was
VideoChunk[] — but that was a lie because Blob doesn't survive
JSON serialization across the port boundary).
- Decode each wire chunk via base64ToBlob(wire.data, wire.type) and
resolve VideoBufferResponse with the resulting VideoChunk[]. The
existing mergeVideoChunks downstream sees real Blobs and produces
a real WebM-prefixed merged blob.
- Defensive per-chunk decode: log + skip individual decode failures
rather than blowing up the whole fetch. Falls back to
video/webm;codecs=vp9 if the wire chunk somehow omits the type
(defense-in-depth — the offscreen always populates it).
- Document the 2 s BUFFER_FETCH_TIMEOUT_MS budget: covers worst-case
encode + post-message + JSON parse with > 1.5 s of headroom for
the current 15-chunk × 100 KB sizing.
Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
- Convert each VideoChunk's Blob to a TransferredVideoChunk via
blobToBase64 before keepalivePort.postMessage. JSON.stringify(blob)
=== "{}" across extension contexts, so the previous direct send of
VideoChunk[] was silently corrupting binary content on the wire.
- Move the work into encodeAndSendBuffer() to keep onPortMessage
synchronous-typed (chrome.runtime.Port.onMessage ignores return
values; the listener stays void-returning, the async work is
fire-and-forget).
- Defensive per-chunk encode: log + skip individual encoding failures
rather than crashing the whole BUFFER response. Operators get partial
video > no video.
- Re-check keepalivePort !== null AFTER the await: the port may have
disconnected during encoding (~100 ms for 15 chunks of ~100 KB each
per the d12 sizing estimate).
Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
- Add TransferredVideoChunk { data: string (base64); type: string
(MIME); timestamp: number; isFirst?: boolean } as the
JSON-serializable shape for chunks traversing the offscreen↔SW
chrome.runtime.Port boundary.
- Retarget PortMessage.chunks?: TransferredVideoChunk[] (was
VideoChunk[]). The in-memory VideoChunk type is unchanged — both
the offscreen ring buffer and the SW-side merge step keep using
it after decoding the wire payload.
- No behavior change yet; this commit only lands the type. The
encode/decode call sites land in the next two commits.
Refs: debug session d12-blob-port-transfer-fails.
- 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.
After collapsing vite.config.ts to use rollupOptions.input.offscreen =
'src/offscreen/index.html', crxjs preserves the 'src/' prefix in the
bundled output (Outcome A per RESEARCH.md Pitfall 5 dichotomy):
dist/src/offscreen/index.html (NOT dist/offscreen/index.html)
The pre-amendment leftover string 'offscreen/index.html' at
src/background/index.ts:45 would have produced
ERR_FILE_NOT_FOUND in chrome.offscreen.createDocument and broken
Plan 07's manual smoke load. Updated to match the actual emit path.
- npm run build exits 0; 7 dist/assets/*.js bundles produced
- dist/manifest.json permissions: desktopCapture present, tabCapture absent
- tsc --noEmit clean; 9/9 vitest tests still green
- ensureOffscreen URL string now matches dist/src/offscreen/index.html
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 05 Task 2 — make the SW a pure coordinator that talks to the offscreen
via the long-lived 'video-keepalive' port (D-17, RESEARCH.md Pattern 5).
Additions:
- chrome.runtime.onConnect.addListener handler scoped to port name
'video-keepalive' + T-1-04 mitigation (port.sender?.id check). Stores port
in module-level videoPort: chrome.runtime.Port | null.
- getVideoBufferFromOffscreen(): port-based REQUEST_BUFFER round-trip with
a 2s timeout fallback to { chunks: [] }. Replaces the synchronous SW-local
getVideoBuffer() stub from Task 1.
- offscreenReady Promise + OFFSCREEN_READY onMessage case (RESEARCH.md
Pattern 4): startVideoCapture awaits this before sending START_RECORDING,
closing the 'Receiving end does not exist' race window (audit P1 #12).
- onMessage GET_VIDEO_BUFFER + SAVE_ARCHIVE rewritten to fetch the buffer
via the port instead of the deleted local array.
- onMessage sender.id !== chrome.runtime.id guard at handler top
(T-1-NEW-05-01 mitigation).
- chrome.runtime.onInstalled now calls indexedDB.deleteDatabase('VideoRecorderDB')
once to clean up the orphaned database from pre-Phase-01 builds
(T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory).
Rule 2 deviation (orchestrator-flagged robustness):
- initialize() now calls chrome.offscreen.hasDocument() to detect existing
offscreen documents across SW respawns and update offscreenCreated
accordingly (audit P1 #8). Guarded with a typeof check to stay safe under
partial chrome stubs.
Verified: npx tsc --noEmit clean; npx vitest run 9/9 green (Plan 04
offscreen-side tests stay un-touched); no as any / @ts-ignore.
Plan 05 Task 1 — finish the SW shrink:
- DELETE videoBuffer: VideoChunk[] module state (buffer lives in offscreen per D-16)
- DELETE setupKeepalive + chrome.alarms registration (D-18; alarms never reset SW idle timer — port does)
- DELETE chrome.tabCapture.getMediaStreamId call (D-01: getDisplayMedia now runs inside offscreen)
- DELETE chrome.permissions.contains/request for tabCapture (broken — desktopCapture is the new manifest entry, but getDisplayMedia needs no runtime perm)
- DELETE comment-only references to removed symbols (so grep gates pass)
- REPLACE 'USER_MEDIA' as any → chrome.offscreen.Reason.DISPLAY_MEDIA (D-02; @types/chrome 0.0.268 exposes it)
- REPLACE justification copy to match RESEARCH.md Example C
- FIX (error as any) → instanceof Error pattern (CLAUDE.md rule)
- FIX chrome.tabs.sendMessage cast: explicit response type instead of 'as any'
- COLLAPSE REQUEST_PERMISSIONS handler: under getDisplayMedia, no runtime perm check is meaningful — just call startVideoCapture() (Rule 1 deviation; old code returned granted=false because tabCapture is no longer in manifest)
- Temporary stub: getVideoBuffer() returns { chunks: [] } — Task 2 deletes this and wires the port-based getVideoBufferFromOffscreen()
Verified: npx tsc --noEmit clean, npx vitest run 9/9 green, no as any / @ts-ignore.
- Update module header to list port keepalive + OFFSCREEN_READY among
the module's owned responsibilities (no longer "wired by Plan 04")
- Replace 'Plan 04 owns the ping loop' on PORT_NAME with the actual
D-17 + Pattern 5 citation
- Replace 'Plan 04 fills the lifecycle' on keepalivePort with its
D-17 + Pattern 5 role
Pure comment cleanup — no behavior change. All 9 tests still GREEN.
- Add PORT_PING_MS (25s) and PORT_RECONNECT_MS (290s) constants
- Replace stub bootstrap with full long-lived port lifecycle:
- connectPort() registers onMessage/onDisconnect listeners, schedules
25s PING postMessages and a 290s pre-emptive reconnect (Pitfall 4
belt-and-braces against Chrome's ~5min port lifetime cap)
- onDisconnect handler synchronously calls connectPort() again
(Plan 02 port.test.ts pins this; flips reconnect test to GREEN)
- REQUEST_BUFFER over the port responds with { type: 'BUFFER',
chunks: getBuffer() } (Plan 05 SW-side will issue REQUEST_BUFFER
on export)
- Keep defensive guard on chrome.runtime sub-APIs so pure ring-buffer
and codec-check tests can import the module without a full chrome stub
- Remove the no-longer-needed 'void keepalivePort' workaround (the
variable is now used by onPortMessage + connectPort)
- T-1-04 mitigation: explicit message-shape switch in onPortMessage
(any unknown port message type silently dropped); comment block
documents the SW-side sender.id check contract for Plan 05
GREEN: all 4 test files in tests/offscreen/ pass (9 tests total —
ring-buffer 4 + codec-check 2 + handshake 1 + port 2).
npx tsc --noEmit exits 0. Zero 'as any' / '@ts-ignore' in recorder.ts.
- Add PortMessageType and PortMessage interface to src/shared/types.ts
for the long-lived port (offscreen ↔ SW; D-17 / Plan 04 wires the
ping loop + REQUEST_BUFFER / BUFFER traffic).
- Remove 'VIDEO_CHUNK' and 'VIDEO_CHUNK_SAVED' from MessageType union
(per D-19 — chunks no longer travel via chrome.runtime.sendMessage;
the IndexedDB SW-side plumbing is the audit P0 #2 broken path).
- OffscreenLogger class was already added alongside Task 2 because
recorder.ts imports it at module top.
Inline SW cleanup (Rule 3 — blocking dependency, plan acceptance gates
on `npx tsc --noEmit` exit 0):
- Remove src/background/index.ts VIDEO_CHUNK + VIDEO_CHUNK_SAVED case
branches (refs to deleted union members).
- Remove now-unreferenced loadChunkFromIndexedDB / openIndexedDB
(only reachable from the deleted VIDEO_CHUNK_SAVED branch).
- Remove now-unreferenced addVideoChunkFromBlob / cleanupVideoBuffer
/ firstChunkSaved / VIDEO_BUFFER_DURATION_MS constant (the SW-side
ring buffer now lives in src/offscreen/recorder.ts per D-16).
- Keep SW-side `videoBuffer: VideoChunk[] = []` as a placeholder; Plan
04 wires it to fetch from offscreen over the keepalive port. The
remaining `getVideoBuffer` + `saveArchive` callers continue to
compile against the empty array until Plan 04 lands.
- Plan 05 owns the broader SW shell cleanup.
Verification (post-commit):
- npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts → 6/6 PASS
- npx tsc --noEmit → exit 0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add src/offscreen/recorder.ts (214 lines) — Phase 01 source of truth
owning getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
buffer with WebM header retention + 30 s age trim, codec strict-mode
(D-20), and track.onended cleanup.
- Add src/offscreen/index.html — crxjs-managed bundle entry referencing
./recorder.ts.
- Add OffscreenLogger class to src/shared/logger.ts (uses ...args:
unknown[] for strict-mode hygiene; legacy Logger / ContentLogger keep
...args: any[] per project provenance). Bundled into this commit
because recorder.ts cannot typecheck without the import (Rule 3 —
blocking dependency).
- Pre-stage D-13 restart-segments fallback as commented skeleton at
bottom of recorder.ts so Plan 07's fallback path needs no re-plan.
- Defensive bootstrap (typeof chrome guard) so the pure ring-buffer +
codec tests can import the module without stubbing the full chrome
surface (Rule 3 — Plan 02 ring-buffer test does not stub chrome).
Flips Plan 02's RED tests to GREEN:
- tests/offscreen/ring-buffer.test.ts — 4 tests passing
- tests/offscreen/codec-check.test.ts — 2 tests passing
Handshake test also passes (single OFFSCREEN_READY emission); port
reconnect test stays RED until Plan 04 wires the reconnect loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>