Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 6ffa242cb9 - Show all commits

View File

@@ -71,108 +71,55 @@ async function ensureOffscreen() {
}
}
// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every
// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE.
chrome.runtime.onConnect.addListener((port) => {
// T-1-04 mitigation: only accept ports from this extension
if (port.name !== 'video-keepalive') {
return;
}
if (port.sender?.id !== chrome.runtime.id) {
logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
port.disconnect();
return;
}
logger.log('Offscreen port connected');
videoPort = port;
// CR-02 fix: install a permanent onMessage sink on every accepted port.
// Chrome 110+ resets the SW idle-timer on any inbound port message, BUT
// in the field, behaviour has been observed to differ subtly when no
// listener is attached at all — Chrome may skip the idle-timer reset
// path entirely on unrouted messages. A no-op listener guarantees the
// PING traffic is consumed and the timer reset is unconditional. The
// per-request listener installed by `getVideoBufferFromOffscreen` still
// handles BUFFER routing; this sink only drains PING and any unknown
// traffic so it doesn't accumulate or surprise us later.
port.onMessage.addListener((msg) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'PING'
) {
// Explicit drain — silences "no listener" semantics in Chrome's
// port-message dispatch and keeps the SW idle-timer reset reliable.
return;
}
// Unknown traffic — drop silently (T-1-04 defense-in-depth).
// BUFFER is routed by the per-request listener in
// getVideoBufferFromOffscreen; that listener fires first when
// attached, so this branch never observes BUFFER in practice.
});
port.onDisconnect.addListener(() => {
logger.log('Offscreen port disconnected; offscreen will reconnect');
videoPort = null;
});
});
// Outer-bound buffer fetch budget. Larger than the legacy
// BUFFER_FETCH_TIMEOUT_MS (was 2 s; per-port-attempt) because the new
// architecture covers MULTIPLE port-replacement retries inside one outer
// budget. 10 s is generous: the inner per-port encode round-trip is
// still ~100-200 ms; the extra headroom covers up to ~50 reconnect
// cycles before the operator-visible error surfaces.
const BUFFER_FETCH_TIMEOUT_MS = 10_000;
// 2 s budget covers the worst-case round-trip: offscreen base64-encodes
// up to ~15 chunks of ~100 KB each (~1.5 MB raw → ~2 MB base64) in
// well under 100 ms, post-message + JSON parse adds < 50 ms, leaving
// plenty of headroom. Bumping later is cheap if real-world recordings
// produce significantly larger buffers; today this is sufficient.
const BUFFER_FETCH_TIMEOUT_MS = 2_000;
// Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The
// onConnect-level message sink routes BUFFER -> resolve by id, so port
// replacement (videoPort changes mid-request) does NOT lose the
// response — the offscreen posts BUFFER on the CURRENT port (whichever
// that is) and our sink picks it up regardless of which Port object it
// arrives on.
interface PendingBufferRequest {
resolve: (resp: VideoBufferResponse) => void;
hardTimer: ReturnType<typeof setTimeout>;
requestId: string;
}
const pendingBufferRequests: Map<string, PendingBufferRequest> = new Map();
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer');
return { segments: [] };
}
const port = videoPort;
return new Promise<VideoBufferResponse>((resolve) => {
const timer = setTimeout(() => {
port.onMessage.removeListener(handler);
// Sweep #5 fix: surface the diagnostic when the timeout fires
// because the port was replaced by a reconnect mid-request.
// The OLD port (captured as `port`) has a dead listener; the
// offscreen will encode-and-send on the NEW port but the
// listener installed there belongs to a different
// getVideoBufferFromOffscreen call (if any). Without this
// diagnostic the operator sees a silent timeout that masquerades
// as an offscreen-side problem. With it, the SW log shows the
// reconnect timing was the proximate cause.
const portReplaced = videoPort !== port;
logger.warn(
`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`,
'port_replaced_during_fetch:', portReplaced,
);
resolve({ segments: [] });
}, BUFFER_FETCH_TIMEOUT_MS);
const handler = (msg: unknown) => {
// Generate a per-request correlation id. Uses crypto.randomUUID when
// available (Chrome 92+ in SW context per
// https://developer.chrome.com/docs/extensions/reference/api/runtime#secure_origin),
// with a Math.random fallback that's still unique enough for in-process
// routing — collisions would require simultaneous in-flight requests
// within the same millisecond on the same SW lifetime, vanishingly
// improbable for this UI flow.
function generateRequestId(): string {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'BUFFER'
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
clearTimeout(timer);
port.onMessage.removeListener(handler);
// D-12 wire format + D-13 segment lifecycle: payload arrives
// as TransferredVideoSegment[] (base64 string + MIME). Decode
// each entry back into a VideoSegment — each is a
// self-contained ~10 s WebM (EBML header + seed keyframe).
// Concatenating them sequentially produces a multi-EBML-header
// file Chrome plays natively. See src/shared/binary.ts +
// RESEARCH.md Pattern 3.
const wireSegments =
(msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
// WR-07 fix: filter empty wire segments BEFORE base64 decode.
// An empty wire.data would decode to a zero-byte Blob; the
// SW-side mergeVideoSegments would then concat it into the
// output WebM, producing a stray empty EBML segment that
// breaks Chrome playback. We split into two passes (filter →
// decode → filter-non-empty) so the iteration semantics stay
// declarative (no early-return in the loop body).
return crypto.randomUUID();
}
return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// Decodes a BUFFER message's wire-format segments into VideoSegment[].
// Extracted from the legacy inline handler so the onConnect sink can
// resolve a pending request without duplicating the decode logic.
function decodeBufferSegments(
wireSegments: TransferredVideoSegment[],
): VideoSegment[] {
// WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty
// wire.data would decode to a zero-byte Blob; mergeVideoSegments would
// then concat it into the output WebM, producing a stray empty EBML
// segment that breaks Chrome playback. Two passes (filter -> decode ->
// filter-non-empty) keep the iteration semantics declarative.
const nonEmptyWires = wireSegments.filter((wire) => {
const isEmpty = !wire.data || wire.data.length === 0;
if (isEmpty) {
@@ -203,11 +150,140 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
return [];
}
});
resolve({ segments });
return segments;
}
// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING/PONG health probe — Option C) — Chrome 110+
// resets the SW idle timer on every port message, AND the PONG reply
// closes the offscreen's health-probe loop; (b) on-demand REQUEST_BUFFER
// round-trip during SAVE_ARCHIVE, routed by requestId so port
// replacement mid-request does not drop the response.
chrome.runtime.onConnect.addListener((port) => {
// T-1-04 mitigation: only accept ports from this extension.
if (port.name !== 'video-keepalive') {
return;
}
if (port.sender?.id !== chrome.runtime.id) {
logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
port.disconnect();
return;
}
logger.log('Offscreen port connected');
videoPort = port;
// CR-02 fix: install a permanent onMessage sink on every accepted port.
// Chrome 110+ resets the SW idle-timer on any inbound port message, BUT
// in the field, behaviour has been observed to differ subtly when no
// listener is attached at all — Chrome may skip the idle-timer reset
// path entirely on unrouted messages.
//
// Option C: this sink ALSO routes BUFFER responses to the matching
// pending request by requestId (the per-request listener pattern is
// gone — it could not handle port replacement). And it echoes PONG on
// every PING so the offscreen's health probe sees life.
port.onMessage.addListener((msg) => {
if (typeof msg !== 'object' || msg === null) {
return;
}
const type = (msg as { type?: unknown }).type;
if (type === 'PING') {
// Health-probe echo (Option C). Wrapped in try/catch because the
// port may have been disconnected between the inbound PING and
// our response — silently drop in that race window.
try {
port.postMessage({ type: 'PONG' });
} catch (err) {
logger.warn('PONG postMessage failed (port closed):', err);
}
return;
}
if (type === 'BUFFER') {
const requestId = (msg as { requestId?: unknown }).requestId;
if (typeof requestId !== 'string' || requestId.length === 0) {
// Defense-in-depth: BUFFER without a valid requestId is invalid
// under the Option C protocol — drop with a warn. (Legacy
// offscreen code that didn't carry requestId is gone.)
logger.warn('BUFFER without a valid requestId — dropping');
return;
}
const pending = pendingBufferRequests.get(requestId);
if (pending === undefined) {
// Stale BUFFER (request already resolved or timed out). Silently
// drop — this is the no-cross-talk property the request-id
// routing guarantees.
return;
}
const wireSegments =
(msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
const segments = decodeBufferSegments(wireSegments);
clearTimeout(pending.hardTimer);
pendingBufferRequests.delete(requestId);
pending.resolve({ segments });
return;
}
// Unknown traffic — drop silently (T-1-04 defense-in-depth).
});
port.onDisconnect.addListener(() => {
logger.log('Offscreen port disconnected; offscreen will reconnect');
if (videoPort === port) {
videoPort = null;
}
});
// If there are pending REQUEST_BUFFER requests at the moment this port
// connects, re-issue them on the fresh port with the SAME requestId.
// This is the architectural mechanism that retires the H2 silent-drop
// class — the BUFFER reaches the SW regardless of port-replacement
// timing. (Note: the FIRST onConnect has pendingBufferRequests.size
// === 0 so this branch correctly does nothing on bootstrap.)
if (pendingBufferRequests.size > 0) {
for (const pending of pendingBufferRequests.values()) {
try {
port.postMessage({
type: 'REQUEST_BUFFER',
requestId: pending.requestId,
});
} catch (err) {
// The fresh port disconnected synchronously — the outer hard
// timer will fire and surface the error.
logger.warn('REQUEST_BUFFER retry post failed:', err);
}
}
}
});
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer');
return { segments: [] };
}
const requestId = generateRequestId();
return new Promise<VideoBufferResponse>((resolve) => {
const hardTimer = setTimeout(() => {
pendingBufferRequests.delete(requestId);
// Outer hard-timeout: covers EVERY retry across port replacements
// (the legacy per-port BUFFER_FETCH_TIMEOUT_MS was 2 s per
// attempt — too tight to retry across a reconnect). 10 s is
// generous; the inner round-trip is still ~100-200 ms.
logger.warn(
`Buffer fetch outer timeout (${BUFFER_FETCH_TIMEOUT_MS} ms) — no BUFFER for requestId ${requestId}`,
);
resolve({ segments: [] });
}, BUFFER_FETCH_TIMEOUT_MS);
pendingBufferRequests.set(requestId, {
resolve,
hardTimer,
requestId,
});
try {
videoPort?.postMessage({ type: 'REQUEST_BUFFER', requestId });
} catch (err) {
// The current port disconnected synchronously. Don't resolve here
// — the offscreen's reconnect will fire a fresh onConnect, the
// sink will detect the in-flight request, and the retry path will
// re-post REQUEST_BUFFER on the new port.
logger.warn('Initial REQUEST_BUFFER post failed (port disconnected):', err);
}
};
port.onMessage.addListener(handler);
port.postMessage({ type: 'REQUEST_BUFFER' });
});
}