diff --git a/src/background/index.ts b/src/background/index.ts index 94662ed..8ebd119 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -87,12 +87,34 @@ chrome.runtime.onConnect.addListener((port) => { } 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; }); - // Inbound traffic is mostly PING (ignored) and BUFFER (handled by the - // per-request listener installed in getVideoBufferFromOffscreen). }); // 2 s budget covers the worst-case round-trip: offscreen base64-encodes @@ -517,6 +539,19 @@ async function initialize() { if (exists) { offscreenCreated = true; logger.log('Existing offscreen document detected on SW init'); + // CR-03 fix: handshake deadlock after SW respawn. + // OFFSCREEN_READY is fired by the offscreen exactly once at its + // bootstrap (recorder.ts line ~452). If the SW is evicted (~30 s + // idle) while the offscreen document persists, the next SW lifetime + // creates a fresh `offscreenReady` Promise (line 33) and waits on + // it forever — the offscreen has no signal to re-emit, and + // startVideoCapture() hangs at `await offscreenReady`. + // Resolve readiness immediately when we detect a pre-existing + // offscreen: it MUST have completed its bootstrap before being + // observable via hasDocument(). + offscreenReadyResolve?.(); + offscreenReadyResolve = null; + logger.log('Resolving offscreenReady immediately — offscreen survived a prior SW lifetime'); } } } catch (err) { diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 6c59881..3fab2f4 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -55,6 +55,12 @@ let segments: Blob[] = []; let currentChunks: Blob[] = []; let rotationTimerId: ReturnType | null = null; let keepalivePort: chrome.runtime.Port | null = null; // long-lived SW keepalive (D-17, Pattern 5) +// WR-03 fix: strictly-monotonic per-process counter for segment timestamps. +// Replaces the previous `Date.now() + idx` scheme, which could collide +// across two REQUEST_BUFFER calls within the same millisecond. The SW-side +// `mergeVideoSegments` sorts by this `timestamp` ascending; a pure counter +// guarantees deterministic ordering with zero wall-clock dependency. +let segmentSeq = 0; // ─── Сегментный буфер (pure functions — testable in Node) ─────────────── @@ -333,58 +339,74 @@ function onPortMessage(message: unknown): void { } async function encodeAndSendBuffer(): Promise { - // Снимок завершённых сегментов + опциональный «свежий» текущий - // сегмент, если уже накопились dataavailable-чанки. Это нужно, чтобы - // SAVE_ARCHIVE через 3 секунды после старта первой сессии не вернул - // пустой буфер — операторская UX страдает иначе. Если currentChunks - // пуст, in-flight сегмент не добавляем. + // CR-01 fix: capture the port identity BEFORE the await. If `keepalivePort` + // is replaced by a fresh reconnect during base64 encoding, posting on the + // new port would silently leak the BUFFER to a stranger — the SW's + // per-request listener is still bound to the OLD port. The SW already + // times out cleanly (BUFFER_FETCH_TIMEOUT_MS = 2 s), so dropping a stale + // response on the floor is the correct behaviour: the next SAVE_ARCHIVE + // round-trip will REQUEST_BUFFER on the fresh port. + const portAtRequest = keepalivePort; + if (portAtRequest === null) { + logger.warn('encodeAndSendBuffer called without an active port — drop'); + return; + } + // WR-09 fix: an in-flight segment lacks the Matroska finalization that + // MediaRecorder.stop() performs (SegmentSize, Cues) — splicing it onto + // a finalized tail re-introduces the "File ended prematurely" symptom + // documented in the debug session webm-playback-freeze. We still want + // SOME data if SAVE_ARCHIVE fires within the first 10 s window (before + // any segment rotation has completed); in that single case we accept + // the trade-off and ship the in-flight Blob alone. Once we have at + // least one finalized segment, we drop the in-flight tail unconditionally. const finalized = getSegments(); const inFlight = - currentChunks.length > 0 + finalized.length === 0 && currentChunks.length > 0 ? new Blob(currentChunks, { type: 'video/webm' }) : null; const allSegments: Blob[] = - inFlight !== null ? [...finalized, inFlight] : finalized; + inFlight !== null ? [inFlight] : finalized; - // Метка времени для каждого сегмента — момент текущего экспорта; даёт - // SW-side `mergeVideoSegments` стабильный порядок сортировки и не зависит - // от часов внутри MediaRecorder. Старший сегмент — самый старый. - const baseTimestamp = Date.now(); - - // Per-segment defensive encode: если одиночный Blob не зенкодится - // (например, неожиданный detach ArrayBuffer-а на лету), логируем и - // пропускаем — частичное видео > отсутствующее видео. - const encodeResults = await Promise.all( - allSegments.map(async (segment, idx): Promise => { - try { - const data = await blobToBase64(segment); - return { - data, - type: segment.type || 'video/webm', - // Порядковый offset — старшие сегменты получают меньший timestamp. - // Шаг произвольный, лишь бы сохранил порядок при сортировке в SW. - timestamp: baseTimestamp + idx, - }; - } catch (err) { - logger.error( - 'blobToBase64 failed; skipping segment', - 'index:', idx, - 'size:', segment.size, - 'error:', err, - ); - return null; - } - }), - ); - const transferred: TransferredVideoSegment[] = encodeResults.filter( - (c): c is TransferredVideoSegment => 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'); + // WR-03 fix: monotonically-increasing per-process counter, NOT + // `Date.now() + idx`. Date.now() at millisecond resolution collides + // across two REQUEST_BUFFER calls within the same millisecond (e.g., + // diagnostic prefetch + real save). The merge code on the SW side + // sorts by `timestamp` ascending, so a strictly monotonic counter + // guarantees a deterministic order independent of wall-clock skew. + const transferred: TransferredVideoSegment[] = []; + for (let idx = 0; idx < allSegments.length; idx++) { + const segment = allSegments[idx]; + try { + const data = await blobToBase64(segment); + transferred.push({ + data, + type: segment.type || 'video/webm', + timestamp: ++segmentSeq, + }); + } catch (err) { + // Per-segment defensive encode: a single Blob failing to encode + // (e.g. unexpected ArrayBuffer detach) is logged and skipped — + // partial video > no video at all. + logger.error( + 'blobToBase64 failed; skipping segment', + 'index:', idx, + 'size:', segment.size, + 'error:', err, + ); + } + } + // After the await: refuse to post on a port that has been REPLACED + // by reconnect. The SW listens on the OLD port; posting on the NEW + // port would silently lose the data. Letting the SW time out is + // correct — the next SAVE_ARCHIVE will re-issue REQUEST_BUFFER on + // the fresh port. + if (keepalivePort !== portAtRequest) { + logger.warn( + 'port replaced during encode; dropping BUFFER response (SW will time out and retry)', + ); return; } - keepalivePort.postMessage({ type: 'BUFFER', segments: transferred }); + portAtRequest.postMessage({ type: 'BUFFER', segments: transferred }); } function connectPort(): void {