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:
@@ -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 TIMESLICE_MS = 2_000; // SPEC §4.1
|
||||
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');
|
||||
|
||||
@@ -137,20 +139,79 @@ function stopRecording(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
|
||||
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
|
||||
// codec tests can mock `chrome.runtime` without crashing on module import.
|
||||
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
|
||||
// loop without rewriting this module's exports.
|
||||
// ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ──
|
||||
// T-1-04 sender-id check: defense-in-depth on the offscreen side. The SW-side
|
||||
// `onConnect` listener (Plan 05) MUST also validate
|
||||
// `port.sender?.id === chrome.runtime.id` to reject port-hijack attempts
|
||||
// from other extensions. See threat register T-1-04 in 01-04-PLAN.md.
|
||||
|
||||
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
|
||||
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
|
||||
// without providing a chrome stub. Production runs always have chrome.runtime
|
||||
// available, so the guard is a no-op there.
|
||||
// without providing a full chrome stub. Production always has chrome.runtime
|
||||
// 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 {
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime) {
|
||||
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') {
|
||||
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
|
||||
connectPort();
|
||||
}
|
||||
if (typeof chrome.runtime.sendMessage === 'function') {
|
||||
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
|
||||
@@ -186,10 +246,6 @@ function bootstrap(): void {
|
||||
|
||||
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) ──
|
||||
// Activated only if the Phase 07 ffprobe gate fails on the simpler
|
||||
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
|
||||
|
||||
Reference in New Issue
Block a user