- Mark .planning/debug/d12-blob-port-transfer-fails.md as status: resolved; fill in the Resolution section with the applied fix (5 commit hashes, files changed), verification output (15/15 tests, tsc clean, vite build green, zero as-any/ts-ignore in fix-touched files), and inline answers to the specialist-review questions raised by the planner. Move the file to .planning/debug/resolved/. - Update STATE.md frontmatter (stopped_at) + Decisions log + Session Continuity to record the D-12 fix landing and the open Plan 07 ffprobe gate (still requires operator smoke.sh + ffprobe re-run before Phase 1 can close). - Land smoke.sh — the operator's D-12 acceptance-gate harness that surfaced the original failure. Self-contained: dedicated /tmp/mokosh-smoke-profile, auto-accept desktop-capture picker, Downloads polling, ffprobe gate, fixture staging. REQ-video-ring-buffer remains NOT-complete — Plan 07 owns it, operator must re-run ./smoke.sh to verify the fix end-to-end in Chrome. Refs: debug session d12-blob-port-transfer-fails (resolved).
14 KiB
slug, status, trigger, created, updated, resolved, phase, plan
| slug | status | trigger | created | updated | resolved | phase | plan |
|---|---|---|---|---|---|---|---|
| d12-blob-port-transfer-fails | resolved | Phase 1 D-12 ffprobe gate failure surfaced during /gsd-execute-phase 1 Plan 01-07 manual smoke | 2026-05-15 | 2026-05-15 | 2026-05-15 | 1 | 01-07 |
Debug session — D-12 Blob/port transfer fails
Symptoms
- Expected: Saved
session_report_*.zipcontainsvideo/last_30sec.webm≈ 1.5 MB of binary VP9 WebM video that ffprobe accepts as Matroska/WebM with a valid EBML header and one playable video stream. - Actual: The WebM file is 75 bytes of raw text:
"[object Object][object Object][object Object][object Object][object Object]"(5×"[object Object]", 5×15 = 75 bytes). ffprobe rejects withEBML header parsing failed; ffprobe's-show_streamslists a single stream of typesubtitle, codec_nametext, codec_long_nameraw UTF-8 text— empirical proof the file payload is text, not video. - Error messages:
Truncating packet of size 8810 to 71[matroska,webm @ 0x...] EBML header parsing failedInvalid data found when processing input- ffprobe exit code 1
- Timeline: First execution of Plan 01-07 (the D-12 acceptance gate) during /gsd-execute-phase 1 on 2026-05-15. Phase 1 Plans 01-01 through 01-06 all green (9/9 unit tests pass, build clean). Recording itself ran for ~35 s (operator clicked extension, share-screen picker auto-accepted via
--auto-select-desktop-capture-source="Mokosh Smoke Test", waited, clicked "Сохранить отчёт об ошибке"). - Reproduction:
cd /home/parf/projects/work/repremium && npm run build(already done; dist/ is current)- Load
dist/unpacked in Chrome 148.0.7778.167 stable - Run
./smoke.sh— Chrome launches with--auto-select-desktop-capture-source="Mokosh Smoke Test"+ smoke profile at/tmp/mokosh-smoke-profile - Click extension icon, wait ~35 s, click "Сохранить отчёт об ошибке"
- Latest archive at
~/Downloads/session_report_2026-05-15_19-42-01.zip unzip -p <zip> video/last_30sec.webm > /tmp/last.webm; ffprobe -v error -f matroska -i /tmp/last.webm
- Archive forensics already collected:
meta.json.totalEvents: 0(content script doesn't run ondata:URLs; orthogonal — known limitation, not part of this bug)events.json: [](same reason as above)rrweb/session.json= 2 bytes ([], same)screenshot.png= 97 895 bytes (real PNG — confirmschrome.tabs.captureVisibleTabworks fine; image data goes throughdataURLstring so escapes the suspected serialization bug)
Current Focus
- hypothesis:
chrome.runtime.connectport messages are JSON-serialized when crossing extension contexts (offscreen ↔ service worker).JSON.stringify(blob)returns"{}"because Blob has no enumerable own properties. The port handler insrc/offscreen/recorder.ts:174-178sends{ type: 'BUFFER', chunks: getBuffer() }where each chunk has.data: Blob. After JSON round-trip the SW receives chunks where each.datais a plain empty object{}. The SW then callsnew Blob([...chunkDataArray], { type: 'video/webm' })atsrc/background/index.ts:213-217; the Blob constructor stringifies non-Blob members viaString({})which yields"[object Object]". Concatenating 5 such strings (no commas because Blob ctor doesn't insert separators, unlike Array.toString) produces exactly 75 bytes — matching the observed payload to the byte. - next_action: Write a RED unit test in
tests/offscreen/port-serialization.test.ts(or similar) that proves the failure mode empirically. The test should: (1) build a fakechrome.runtime.connectport whosepostMessageruns the payload throughstructuredClone(JSON.parse(JSON.stringify(msg)))— Chrome's documented cross-context behavior — and (2) assert that after the round-trip,received.chunks[0].data instanceof BlobisfalseANDString(received.chunks[0].data) === "[object Object]". Once the test FAILS as predicted (which means the hypothesis IS reproduced), we have the RED gate. The fix then converts Blob → ArrayBuffer in offscreen before postMessage and ArrayBuffer → Blob in SW after receive (ArrayBuffer IS structured-cloneable across extension contexts). - expecting: RED test goes red. If it goes green instead — hypothesis is wrong; reopen investigation.
- reasoning_checkpoint: the math (5×15 = 75) and the JSON.stringify(blob) = "{}" chain explain every digit of the observed payload, but the test is the empirical seal. I have NOT yet observed the bug via instrumentation (no console.log dump showing
chunk.data instanceof Blobtrue at port-send and false at port-receive). The test is the cheapest way to seal that gap. - specialist_hint: chrome-extension-mv3 or browser-platform; the bug is at the chrome.runtime port boundary, not in our application logic. The fix pattern (ArrayBuffer transfer for binary data across extension contexts) is documented in Chrome's developer docs and verified in production extensions (per Phase 1 RESEARCH.md Patterns 4+5 references).
Evidence
- timestamp: 2026-05-15T17:50:37Z — Baseline test run: 9/9 existing tests green (
npm test -- --run). Confirms the regression introduced by the new RED test is isolated and not a side-effect. - timestamp: 2026-05-15T17:51:00Z — Source-file inspection confirms hypothesis-relevant code locations exactly as predicted:
src/offscreen/recorder.ts:168-180—onPortMessagehandler:keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }). Each chunk invideoBufferis built byaddChunk(blob: Blob, ts: number)(line 31-43), sochunk.dataIS a Blob at send-time.src/background/index.ts:71-89— SW-side port host. Handler chaingetVideoBufferFromOffscreen→ port message handler at lines 105-116 reads(msg as { chunks?: VideoChunk[] }).chunksdirectly with no Blob reconstruction.src/background/index.ts:204-222—mergeVideoChunks:const blobs: Blob[] = sortedChunks.map((chunk) => chunk.data); new Blob(blobs, { type: 'video/webm' }). After the JSON round-trip,chunk.datais{}, soblobsis[{}, {}, …], and the Blob ctor coerces each member viaString({})="[object Object]".src/shared/types.ts:36-40—VideoChunk { data: Blob; timestamp: number; isFirst?: boolean }. The type declaration is sound; the bug is the unstated assumption that this shape survives the port transport.
- timestamp: 2026-05-15T17:51:30Z — Forensic byte-level confirmation:
hexdump -C /tmp/mokosh-last_30sec.webmyields 75 bytes = 5 × 16-byte stripes of5b 6f 62 6a 65 63 74 20 4f 62 6a 65 63 74 5d(=[object Object]with no separator). Matches predicted output byte-for-byte. Independently confirms that exactly 5 chunks were in the SW's view of the buffer at SAVE_ARCHIVE time, which is consistent with a 30 s recording at TIMESLICE_MS=2000 producing 15 chunks of which the age-trim keeps a tail (further forensics not needed — the failure mode is sealed by the test below). - timestamp: 2026-05-15T17:52:44Z — RED test written and executed:
tests/offscreen/port-serialization.test.ts. 6/6 tests PASS. Critical assertions:JSON.stringify(new Blob([4 bytes])) === "{}"— confirmed.JSON.parse(JSON.stringify({chunks: [{data: blob}]}))yieldschunks[0].data === {}(not a Blob, no instanceof Blob) — confirmed.new Blob([{}, {}, {}, {}, {}]).size === 75AND its.text() === "[object Object][object Object][object Object][object Object][object Object]"— confirmed byte-exact match to the observed payload.- End-to-end test: 5 real Blob chunks → JSON round-trip → SW-side
mergeVideoChunks-equivalent → output is exactly 75 bytes of the same"[object Object]"repetition. - Forward-pin GREEN block: base64 round-trip preserves the EBML magic bytes (
0x1a 0x45 0xdf 0xa3) intact acrossJSON.parse(JSON.stringify(...)). This pins the fix's wire-format contract.
- timestamp: 2026-05-15T17:52:52Z — Full suite still green: 15/15 tests pass (9 baseline + 6 new). No regression in
recorder.tsor any other surface.
Eliminated
- Not a MediaRecorder issue. Existing
tests/offscreen/codec-check.test.tsandring-buffer.test.tsexercise the recorder side and show valid Blob accumulation with correct sizes. The recorder.ts module captures Blobs with.size > 0; the corruption is purely on the wire. - Not a Blob-ctor type-tag issue. The SW passes
{ type: 'video/webm' }correctly; if Blob members were preserved this would produce a valid WebM. The corruption is the contents of the array passed to the ctor, not the type tag. - Not a JSZip issue. Plan 01-07 forensics showed
screenshot.png= 97 895 bytes intact, because the screenshot goes Blob → dataURL → fetch → Blob inside the SW (not through a port). JSZip handles Blob input correctly; the input it received was already 75 bytes of garbage. - Not a Chrome version regression. Per Chrome MV3 docs (https://developer.chrome.com/docs/extensions/develop/concepts/messaging),
chrome.runtimemessaging has ALWAYS used JSON serialization across contexts. The "structured clone" mention in some docs refers only to same-process messaging (popup→SW within the same renderer); offscreen↔SW crosses a process boundary. - Not a
videoPort === nullissue. Even though the SW-sidevideoPortis reassigned per-connection, thegetVideoBufferFromOffscreenrequest/response cycle completed (5 chunks arrived — not 0). If port disconnect were the issue, we would see 0 chunks → empty merge → 0-byte WebM, not 75 bytes. - Not a TIMESLICE/timing issue. The 5 chunks suggest 10 seconds' worth of timeslices at 2000ms each; could be a partial buffer after age-trim or a short recording. Either way, the byte-level evidence shows the chunks are well-formed metadata + corrupted Blob, which is exactly what JSON serialization predicts.
Resolution
-
root_cause:
chrome.runtime.Port.postMessageJSON-serializes payloads across extension contexts (offscreen ↔ service worker).JSON.stringify(blob)returns"{}"becauseBlobhas no enumerable own properties. The receive side readschunks[i].dataas{}(a plain object), then passes the array[{}, {}, ...]tonew Blob(...). The Blob constructor coerces non-Blob members viaString({})="[object Object]", concatenated with no separator, yielding exactly 75 bytes for 5 chunks. Verified empirically bytests/offscreen/port-serialization.test.ts(6/6 PASS, including byte-exact reproduction of the observed 75-byte payload). -
fix (applied 2026-05-15): Base64 wire-format encode/decode across the offscreen↔SW port. Four atomic source commits + one docs commit on
gsd/phase-01-stabilize-video-pipeline:# Commit Subject 1 c0d9166feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts 2 d653283feat(fix-d12): add TransferredVideoChunk wire-format type in src/shared/types.ts 3 2831849feat(fix-d12): encode chunks to base64 in offscreen REQUEST_BUFFER handler 4 d5bb948feat(fix-d12): decode chunks from base64 in SW BUFFER receive 5 (next) docs(fix-d12): resolve debug session and update STATE Files created:
src/shared/binary.ts(portable Blob↔base64 helpers, mirroring the GREEN-block algorithm fromport-serialization.test.ts). Files modified:src/shared/types.ts(addedTransferredVideoChunk, retargetedPortMessage.chunks),src/offscreen/recorder.ts(encode-and-send via fire-and-forget IIFE, per-chunk defensive encode, re-checkkeepalivePort !== nullafterawait),src/background/index.ts(decode ingetVideoBufferFromOffscreenBUFFER handler, per-chunk defensive decode withvideo/webm;codecs=vp9fallback MIME). -
verification:
npx vitest run→ 5 files passed, 15 tests passed (9 baseline + 6 new port-serialization). Both GREEN-block tests confirm the wire format: (a) base64 round-trip preserves the EBML magic bytes0x1A 0x45 0xDF 0xA3acrossJSON.parse(JSON.stringify(...)); (b) merging base64-decoded chunks yields a real WebM-prefixed Blob (size = 4 ≠ 75).npx tsc --noEmit→ exit 0, no errors.npm run build→ vite v5.4.21 built in 1.45 s; freshdist/includesdist/assets/binary-y3zCmpDG.jsproving the new module is bundled into the offscreen + SW chunks.grep -RIn "as any\|@ts-ignore" src/{shared,offscreen,background}→ zero violations in fix-touched files.- End-to-end smoke (
./smoke.sh+ ffprobe gate) is the operator's next step — the unit-test contract is the wire-format proof; browser-runtime exercise validates port stability under real MediaRecorder load. Not part of this fix's scope (Plan 07 owns it).
-
specialist review (resolved inline):
- Base64 vs alternatives: kept (~33% inflation × ~1.5 MB raw ≈ 2 MB string per export — within Chrome's ~50 MB port message limit by a wide margin). OPFS / chunked IDB were higher-engineering alternatives unjustified given the size envelope.
- Async
onPortMessagereordering: ruled out —saveArchiveis the only caller and serializes throughisRecording. The fire-and-forget IIFE keeps the listener signature synchronous (port API ignores return values).
-
TDD gate status: RED block still passes (it tests browser-documented JSON behavior, not our code path). GREEN block now also passes against the production-helper wire-format contract. Production code routes through the same encode/decode algorithm as the GREEN-block helpers, by import from
src/shared/binary.ts.