feat(01-04): wire offscreen port keepalive and OFFSCREEN_READY handshake

- Add PORT_PING_MS (25s) and PORT_RECONNECT_MS (290s) constants
- Replace stub bootstrap with full long-lived port lifecycle:
  - connectPort() registers onMessage/onDisconnect listeners, schedules
    25s PING postMessages and a 290s pre-emptive reconnect (Pitfall 4
    belt-and-braces against Chrome's ~5min port lifetime cap)
  - onDisconnect handler synchronously calls connectPort() again
    (Plan 02 port.test.ts pins this; flips reconnect test to GREEN)
  - REQUEST_BUFFER over the port responds with { type: 'BUFFER',
    chunks: getBuffer() } (Plan 05 SW-side will issue REQUEST_BUFFER
    on export)
- Keep defensive guard on chrome.runtime sub-APIs so pure ring-buffer
  and codec-check tests can import the module without a full chrome stub
- Remove the no-longer-needed 'void keepalivePort' workaround (the
  variable is now used by onPortMessage + connectPort)
- T-1-04 mitigation: explicit message-shape switch in onPortMessage
  (any unknown port message type silently dropped); comment block
  documents the SW-side sender.id check contract for Plan 05

GREEN: all 4 test files in tests/offscreen/ pass (9 tests total —
ring-buffer 4 + codec-check 2 + handshake 1 + port 2).
npx tsc --noEmit exits 0. Zero 'as any' / '@ts-ignore' in recorder.ts.
This commit is contained in:
2026-05-15 17:46:33 +02:00
parent 30e5efd364
commit b064a214b2

View File

@@ -14,6 +14,8 @@ const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallbac
const VIDEO_BITRATE = 400_000; // CON-video-codec const VIDEO_BITRATE = 400_000; // CON-video-codec
const TIMESLICE_MS = 2_000; // SPEC §4.1 const TIMESLICE_MS = 2_000; // SPEC §4.1
const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
const PORT_PING_MS = 25_000; // < 30 s SW idle threshold
const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4)
const logger = new OffscreenLogger('Recorder'); const logger = new OffscreenLogger('Recorder');
@@ -137,20 +139,79 @@ function stopRecording(): void {
} }
} }
// ─── Bootstrap (Plan 04 wires the full port + handshake) ──────────────── // ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ──
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer + // T-1-04 sender-id check: defense-in-depth on the offscreen side. The SW-side
// codec tests can mock `chrome.runtime` without crashing on module import. // `onConnect` listener (Plan 05) MUST also validate
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect // `port.sender?.id === chrome.runtime.id` to reject port-hijack attempts
// loop without rewriting this module's exports. // from other extensions. See threat register T-1-04 in 01-04-PLAN.md.
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean { function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
return sender?.id === chrome.runtime.id; return sender?.id === chrome.runtime.id;
} }
// Бутстрап (Plan 03 stub; Plan 04 wires the full handshake + reconnect). // Stable handles for the ping interval and the pre-emptive reconnect timer,
// so we can clear them on disconnect / re-init.
let pingIntervalId: ReturnType<typeof setInterval> | null = null;
let preemptiveReconnectId: ReturnType<typeof setTimeout> | null = null;
function teardownPortTimers(): void {
if (pingIntervalId !== null) {
clearInterval(pingIntervalId);
pingIntervalId = null;
}
if (preemptiveReconnectId !== null) {
clearTimeout(preemptiveReconnectId);
preemptiveReconnectId = null;
}
}
function onPortMessage(message: unknown): void {
// Defense-in-depth: explicit shape check before destructuring (T-1-04)
if (typeof message !== 'object' || message === null) {
return;
}
const type = (message as { type?: unknown }).type;
if (type === 'REQUEST_BUFFER') {
if (keepalivePort !== null) {
keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() });
}
}
// Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
}
function connectPort(): void {
teardownPortTimers();
try {
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
} catch (err) {
logger.error('port connect failed:', err);
keepalivePort = null;
return;
}
keepalivePort.onMessage.addListener(onPortMessage);
keepalivePort.onDisconnect.addListener(() => {
logger.warn('port disconnected — reconnecting');
teardownPortTimers();
keepalivePort = null;
// Synchronous reconnect — tests/offscreen/port.test.ts pins this
connectPort();
});
pingIntervalId = setInterval(() => {
keepalivePort?.postMessage({ type: 'PING' });
}, PORT_PING_MS);
preemptiveReconnectId = setTimeout(() => {
logger.log('pre-emptive port reconnect (290 s cap)');
keepalivePort?.disconnect();
// onDisconnect handler above triggers a fresh connectPort() call
}, PORT_RECONNECT_MS);
}
// Бутстрап (handshake + port lifecycle).
// Guarded so the pure ring-buffer + codec-check tests can import the module // Guarded so the pure ring-buffer + codec-check tests can import the module
// without providing a chrome stub. Production runs always have chrome.runtime // without providing a full chrome stub. Production always has chrome.runtime
// available, so the guard is a no-op there. // fully populated, so the guard is a no-op there. Order matters per Pattern 4:
// onMessage listener registration MUST happen BEFORE OFFSCREEN_READY is sent,
// so the SW can safely send START_RECORDING the moment it sees the ready signal.
function bootstrap(): void { function bootstrap(): void {
if (typeof chrome === 'undefined' || !chrome.runtime) { if (typeof chrome === 'undefined' || !chrome.runtime) {
return; return;
@@ -175,9 +236,8 @@ function bootstrap(): void {
} }
}); });
} }
// Plan 04 will replace this stub with the full reconnect + ping loop.
if (typeof chrome.runtime.connect === 'function') { if (typeof chrome.runtime.connect === 'function') {
keepalivePort = chrome.runtime.connect({ name: PORT_NAME }); connectPort();
} }
if (typeof chrome.runtime.sendMessage === 'function') { if (typeof chrome.runtime.sendMessage === 'function') {
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
@@ -186,10 +246,6 @@ function bootstrap(): void {
bootstrap(); bootstrap();
// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
void keepalivePort;
// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ── // ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
// Activated only if the Phase 07 ffprobe gate fails on the simpler // Activated only if the Phase 07 ffprobe gate fails on the simpler
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3. // continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.