---
slug: d12-blob-port-transfer-fails
status: resolved
trigger: Phase 1 D-12 ffprobe gate failure surfaced during /gsd-execute-phase 1 Plan 01-07 manual smoke
created: 2026-05-15
updated: 2026-05-15
resolved: 2026-05-15
phase: 1
plan: 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-180` — `onPortMessage` 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-222` — `mergeVideoChunks`: `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-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.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 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 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 --noEmit` → **exit 0**, no errors.
  - `npm run build` → **vite 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`.
