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.
This commit is contained in:
2026-05-15 20:16:41 +02:00
parent d653283bc4
commit 283184978f

View File

@@ -6,7 +6,8 @@
// OFFSCREEN_READY handshake (Pattern 4). // OFFSCREEN_READY handshake (Pattern 4).
import { OffscreenLogger } from '../shared/logger'; 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) ────────────────── // ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд 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; const type = (message as { type?: unknown }).type;
if (type === 'REQUEST_BUFFER') { if (type === 'REQUEST_BUFFER') {
if (keepalivePort !== null) { // Fire-and-forget: the chrome.runtime.Port.onMessage listener API
keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }); // 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). // Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
} }
async function encodeAndSendBuffer(): Promise<void> {
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<TransferredVideoChunk | null> => {
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 { function connectPort(): void {
teardownPortTimers(); teardownPortTimers();
try { try {