Files
mokosh/.planning/debug/resolved/d12-blob-port-transfer-fails.md
Mark bf076199b4 docs(fix-d12): resolve debug session and update STATE
- 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).
2026-05-15 20:23:29 +02:00

14 KiB
Raw Permalink Blame History

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_*.zip contains video/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 with EBML header parsing failed; ffprobe's -show_streams lists a single stream of type subtitle, codec_name text, codec_long_name raw 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 failed
    • Invalid 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:
    1. cd /home/parf/projects/work/repremium && npm run build (already done; dist/ is current)
    2. Load dist/ unpacked in Chrome 148.0.7778.167 stable
    3. Run ./smoke.sh — Chrome launches with --auto-select-desktop-capture-source="Mokosh Smoke Test" + smoke profile at /tmp/mokosh-smoke-profile
    4. Click extension icon, wait ~35 s, click "Сохранить отчёт об ошибке"
    5. Latest archive at ~/Downloads/session_report_2026-05-15_19-42-01.zip
    6. 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 on data: 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 — confirms chrome.tabs.captureVisibleTab works fine; image data goes through dataURL string so escapes the suspected serialization bug)

Current Focus

  • hypothesis: chrome.runtime.connect port 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 in src/offscreen/recorder.ts:174-178 sends { type: 'BUFFER', chunks: getBuffer() } where each chunk has .data: Blob. After JSON round-trip the SW receives chunks where each .data is a plain empty object {}. The SW then calls new Blob([...chunkDataArray], { type: 'video/webm' }) at src/background/index.ts:213-217; the Blob constructor stringifies non-Blob members via String({}) 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 fake chrome.runtime.connect port whose postMessage runs the payload through structuredClone(JSON.parse(JSON.stringify(msg))) — Chrome's documented cross-context behavior — and (2) assert that after the round-trip, received.chunks[0].data instanceof Blob is false AND String(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 Blob true 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-180onPortMessage handler: keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }). Each chunk in videoBuffer is built by addChunk(blob: Blob, ts: number) (line 31-43), so chunk.data IS a Blob at send-time.
    • src/background/index.ts:71-89 — SW-side port host. Handler chain getVideoBufferFromOffscreen → port message handler at lines 105-116 reads (msg as { chunks?: VideoChunk[] }).chunks directly with no Blob reconstruction.
    • src/background/index.ts:204-222mergeVideoChunks: const blobs: Blob[] = sortedChunks.map((chunk) => chunk.data); new Blob(blobs, { type: 'video/webm' }). After the JSON round-trip, chunk.data is {}, so blobs is [{}, {}, …], and the Blob ctor coerces each member via String({}) = "[object Object]".
    • src/shared/types.ts:36-40VideoChunk { 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.webm yields 75 bytes = 5 × 16-byte stripes of 5b 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}]})) yields chunks[0].data === {} (not a Blob, no instanceof Blob) — confirmed.
    • new Blob([{}, {}, {}, {}, {}]).size === 75 AND 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 across JSON.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.ts or any other surface.

Eliminated

  • Not a MediaRecorder issue. Existing tests/offscreen/codec-check.test.ts and ring-buffer.test.ts exercise 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.runtime messaging 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 === null issue. Even though the SW-side videoPort is reassigned per-connection, the getVideoBufferFromOffscreen request/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.postMessage JSON-serializes payloads across extension contexts (offscreen ↔ service worker). JSON.stringify(blob) returns "{}" because Blob has no enumerable own properties. The receive side reads chunks[i].data as {} (a plain object), then passes the array [{}, {}, ...] to new Blob(...). The Blob constructor coerces non-Blob members via String({}) = "[object Object]", concatenated with no separator, yielding exactly 75 bytes for 5 chunks. Verified empirically by tests/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 c0d9166 feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts
    2 d653283 feat(fix-d12): add TransferredVideoChunk wire-format type in src/shared/types.ts
    3 2831849 feat(fix-d12): encode chunks to base64 in offscreen REQUEST_BUFFER handler
    4 d5bb948 feat(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 from port-serialization.test.ts). Files modified: src/shared/types.ts (added TransferredVideoChunk, retargeted PortMessage.chunks), src/offscreen/recorder.ts (encode-and-send via fire-and-forget IIFE, per-chunk defensive encode, re-check keepalivePort !== null after await), src/background/index.ts (decode in getVideoBufferFromOffscreen BUFFER handler, per-chunk defensive decode with video/webm;codecs=vp9 fallback MIME).

  • verification:

    • npx vitest run5 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 bytes 0x1A 0x45 0xDF 0xA3 across JSON.parse(JSON.stringify(...)); (b) merging base64-decoded chunks yields a real WebM-prefixed Blob (size = 4 ≠ 75).
    • npx tsc --noEmitexit 0, no errors.
    • npm run buildvite v5.4.21 built in 1.45 s; fresh dist/ includes dist/assets/binary-y3zCmpDG.js proving 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 onPortMessage reordering: ruled out — saveArchive is the only caller and serializes through isRecording. 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.