From 283184978f47287c7c1d443fde8467cd745820f1 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 20:16:41 +0200 Subject: [PATCH] feat(fix-d12): encode chunks to base64 in offscreen REQUEST_BUFFER handler - 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. --- src/offscreen/recorder.ts | 53 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 6cd8ad5..3f524a1 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -6,7 +6,8 @@ // OFFSCREEN_READY handshake (Pattern 4). import { OffscreenLogger } from '../shared/logger'; -import type { Message, VideoChunk } from '../shared/types'; +import { blobToBase64 } from '../shared/binary'; +import type { Message, TransferredVideoChunk, VideoChunk } from '../shared/types'; // ─── Константы (per CON-video-codec, CON-video-window) ────────────────── export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд @@ -172,13 +173,57 @@ function onPortMessage(message: unknown): void { } const type = (message as { type?: unknown }).type; if (type === 'REQUEST_BUFFER') { - if (keepalivePort !== null) { - keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }); - } + // Fire-and-forget: the chrome.runtime.Port.onMessage listener API + // ignores the return value. We do the async base64 encoding inside + // an IIFE so the listener stays synchronous-typed (void return). + // + // D-12 fix: each chunk's Blob is encoded to base64 BEFORE postMessage + // because chrome.runtime.Port JSON-serializes across extension contexts, + // and JSON.stringify(blob) === "{}". See src/shared/binary.ts and + // tests/offscreen/port-serialization.test.ts for the contract. + void encodeAndSendBuffer(); } // Any unknown port message type is silently dropped (T-1-04 defense-in-depth). } +async function encodeAndSendBuffer(): Promise { + const chunks = getBuffer(); + // Per-chunk defensive encode: if a single Blob fails to encode (e.g. + // an unexpected ArrayBuffer detach mid-flight), log the error and skip + // it rather than dropping the entire BUFFER response. Operators get + // partial video > no video. + const encodeResults = await Promise.all( + chunks.map(async (chunk): Promise => { + try { + const data = await blobToBase64(chunk.data); + return { + data, + type: chunk.data.type, + timestamp: chunk.timestamp, + isFirst: chunk.isFirst, + }; + } catch (err) { + logger.error( + 'blobToBase64 failed; skipping chunk', + 'timestamp:', chunk.timestamp, + 'isFirst:', chunk.isFirst, + 'error:', err, + ); + return null; + } + }), + ); + const transferred: TransferredVideoChunk[] = encodeResults.filter( + (c): c is TransferredVideoChunk => c !== null, + ); + // Re-check port AFTER the await: it may have disconnected during encoding. + if (keepalivePort === null) { + logger.warn('port disconnected during base64 encoding; dropping BUFFER response'); + return; + } + keepalivePort.postMessage({ type: 'BUFFER', chunks: transferred }); +} + function connectPort(): void { teardownPortTimers(); try {