fix(01-review): sweep #2+#3+#4 recorder lifecycle hardening (re-entrance + start throw + dual-track teardown)

This commit is contained in:
2026-05-16 10:59:17 +02:00
parent 08a79a61ac
commit 7c91f526d8

View File

@@ -241,6 +241,13 @@ async function startRecording(): Promise<void> {
* MediaStream, навешиваем обработчики, стартуем без timeslice (один * MediaStream, навешиваем обработчики, стартуем без timeslice (один
* dataavailable на остановку → один blob на сегмент → один keyframe в * dataavailable на остановку → один blob на сегмент → один keyframe в
* заголовке за счёт fresh-encoder-инициализации). * заголовке за счёт fresh-encoder-инициализации).
*
* Sweep #3 hardening: the MediaRecorder constructor and .start() may
* throw (codec mid-session unavailability, GPU/driver hot-swap, etc.).
* Without a guard the rotation chain would silently die — onSegmentStopped
* is never called again, no new RECORDING_ERROR is emitted, the popup
* shows green while nothing is recording. Catch + classify + emit +
* tear down the session so the operator gets actionable feedback.
*/ */
function startNewSegment(): void { function startNewSegment(): void {
if (mediaStream === null) { if (mediaStream === null) {
@@ -248,6 +255,7 @@ function startNewSegment(): void {
return; return;
} }
currentChunks = []; currentChunks = [];
try {
videoRecorder = new MediaRecorder(mediaStream, { videoRecorder = new MediaRecorder(mediaStream, {
mimeType: VIDEO_MIME, mimeType: VIDEO_MIME,
videoBitsPerSecond: VIDEO_BITRATE, videoBitsPerSecond: VIDEO_BITRATE,
@@ -262,6 +270,35 @@ function startNewSegment(): void {
// можно дать timeslice назад без изменения семантики ротации. // можно дать timeslice назад без изменения семантики ротации.
videoRecorder.start(); videoRecorder.start();
scheduleRotation(); scheduleRotation();
} catch (err) {
// Sweep #3 fix: MediaRecorder construction / start failed mid-session.
// Most common cause is the codec becoming unavailable (GPU hot-swap,
// driver change). Classify, notify, and tear down so the operator
// sees an actionable error instead of silent recording cessation.
const code = classifyCaptureError(err);
const msg = err instanceof Error ? err.message : String(err);
logger.warn('startNewSegment failed (raw):', msg);
logger.error('startNewSegment failed (code):', code);
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code });
// Tear down — same shape as onUserStoppedSharing's cleanup so the
// SW-side state machine doesn't get a half-recorded session.
const streamToStop = mediaStream;
mediaStream = null;
videoRecorder = null;
if (rotationTimerId !== null) {
clearTimeout(rotationTimerId);
rotationTimerId = null;
}
if (streamToStop !== null) {
streamToStop.getTracks().forEach((t) => {
try {
t.stop();
} catch (terr) {
logger.warn('track.stop() during startNewSegment cleanup failed:', terr);
}
});
}
}
} }
/** /**
@@ -340,7 +377,23 @@ function onDataAvailable(event: BlobEvent): void {
currentChunks.push(event.data); currentChunks.push(event.data);
} }
// Sweep #4 fix: onUserStoppedSharing is registered with `{ once: true }`
// on EACH track of the captured stream. With multiple tracks (video +
// audio — audio currently always disabled per D-13 but the registration
// walks `getTracks()` defensively), the handler could fire twice if
// both tracks emit `ended` in close succession. `resetBuffer()` and the
// stream-cleanup steps are idempotent, but the `chrome.runtime.sendMessage`
// would double-emit RECORDING_ERROR — same double-emit pattern WR-02
// fixed for the codec/getDisplayMedia path. Guard with a flag that
// gates the broadcast + cleanup.
let teardownInProgress = false;
function onUserStoppedSharing(): void { function onUserStoppedSharing(): void {
if (teardownInProgress) {
logger.log('onUserStoppedSharing already ran — second track ended, ignoring');
return;
}
teardownInProgress = true;
logger.log('Operator stopped sharing — clearing buffer'); logger.log('Operator stopped sharing — clearing buffer');
resetBuffer(); resetBuffer();
if (videoRecorder !== null && videoRecorder.state !== 'inactive') { if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
@@ -356,6 +409,14 @@ function onUserStoppedSharing(): void {
} }
videoRecorder = null; videoRecorder = null;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' }); chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
// Reset the guard so a future startRecording → onUserStoppedSharing
// cycle works correctly. Place AFTER the broadcast so a same-tick
// second invocation is still gated.
// Use a microtask deferral (queueMicrotask) so the reset happens after
// every synchronous re-entrant invocation in the same dispatcher tick.
queueMicrotask(() => {
teardownInProgress = false;
});
} }
function stopRecording(): void { function stopRecording(): void {
@@ -441,7 +502,28 @@ function onPortMessage(message: unknown): void {
// 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).
} }
// Sweep #2 fix: in-flight guard against re-entrant encodeAndSendBuffer.
// The SW only issues one REQUEST_BUFFER per saveArchive in production, but
// nothing in the API design forbids back-to-back REQUEST_BUFFER messages.
// Without this guard, two concurrent encode passes would:
// (a) interleave `++segmentSeq` increments — each request's segments
// end up with non-contiguous timestamps that look like gaps from
// the SW sort perspective (benign but noisy)
// (b) both call getSegments() against the same buffer snapshot, so
// the SW would receive two BUFFERs with overlapping content if
// it accidentally combined them
// (c) inflate base64-encode CPU cost unnecessarily during the
// encode latency window (~150 ms for 3 segments)
// The guard drops the second concurrent call with a warn log; the SW
// timeout fires cleanly and the next saveArchive retries on the fresh
// post-completion state.
let encodeInFlight = false;
async function encodeAndSendBuffer(): Promise<void> { async function encodeAndSendBuffer(): Promise<void> {
if (encodeInFlight) {
logger.warn('encodeAndSendBuffer already running — dropping concurrent call');
return;
}
// CR-01 fix: capture the port identity BEFORE the await. If `keepalivePort` // CR-01 fix: capture the port identity BEFORE the await. If `keepalivePort`
// is replaced by a fresh reconnect during base64 encoding, posting on the // 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 // new port would silently leak the BUFFER to a stranger — the SW's
@@ -454,6 +536,15 @@ async function encodeAndSendBuffer(): Promise<void> {
logger.warn('encodeAndSendBuffer called without an active port — drop'); logger.warn('encodeAndSendBuffer called without an active port — drop');
return; return;
} }
encodeInFlight = true;
try {
await doEncodeAndSendBuffer(portAtRequest);
} finally {
encodeInFlight = false;
}
}
async function doEncodeAndSendBuffer(portAtRequest: chrome.runtime.Port): Promise<void> {
// WR-09 fix: an in-flight segment lacks the Matroska finalization that // WR-09 fix: an in-flight segment lacks the Matroska finalization that
// MediaRecorder.stop() performs (SegmentSize, Cues) — splicing it onto // MediaRecorder.stop() performs (SegmentSize, Cues) — splicing it onto
// a finalized tail re-introduces the "File ended prematurely" symptom // a finalized tail re-introduces the "File ended prematurely" symptom