fix(01-review): sweep #1 stopRecording nulls mediaStream first to prevent rotation race

This commit is contained in:
2026-05-16 10:52:59 +02:00
parent a6e2d09de8
commit 08a79a61ac
2 changed files with 46 additions and 3 deletions

View File

@@ -359,16 +359,40 @@ function onUserStoppedSharing(): void {
} }
function stopRecording(): void { function stopRecording(): void {
// Отменяем будущую ротацию, чтобы rotateSegment не дёрнул stop() на // Sweep #1 fix: race between rotateSegment / onSegmentStopped and a
// уже остановленном рекордере. // STOP_RECORDING that does NOT null the mediaStream. The previous
// implementation cleared the rotation timer and called .stop(), which
// triggers onSegmentStopped async; onSegmentStopped then unconditionally
// calls startNewSegment() whenever `mediaStream !== null`. Result: after
// a manual STOP_RECORDING, a new segment would spawn anyway and the
// recorder kept rolling silently. The fix mirrors onUserStoppedSharing:
// null the mediaStream FIRST so onSegmentStopped's gate sees the stopped
// state, then stop the recorder and release the tracks. This also fixes
// the secondary issue of leaked MediaStreamTracks (the picker indicator
// would have stayed up until the operator manually clicked "Stop
// sharing" in Chrome's UI).
if (rotationTimerId !== null) { if (rotationTimerId !== null) {
clearTimeout(rotationTimerId); clearTimeout(rotationTimerId);
rotationTimerId = null; rotationTimerId = null;
} }
// Capture stream before nulling so we can release its tracks.
const streamToStop = mediaStream;
mediaStream = null;
if (videoRecorder !== null && videoRecorder.state !== 'inactive') { if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop(); try {
videoRecorder.stop();
} catch (err) {
logger.warn('stop() during stopRecording failed:', err);
}
logger.log('Recording stopped manually'); logger.log('Recording stopped manually');
} }
if (streamToStop !== null) {
streamToStop.getTracks().forEach((t) => t.stop());
}
// Note: we do NOT call resetBuffer() here — the operator may want to
// STOP and then SAVE the buffered footage. resetBuffer happens on
// onUserStoppedSharing (operator dismissed Chrome's sharing indicator)
// and at the start of the next startRecording().
} }
// ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ── // ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ──

View File

@@ -111,6 +111,25 @@ describe('segment rotation (D-13 restart-segments)', () => {
expect(getSegments()).toEqual([]); expect(getSegments()).toEqual([]);
}); });
it('resetBuffer preserves segments do NOT survive (sweep #1 stop-race adjacent)', () => {
// Sweep #1 documented that stopRecording() previously raced
// onSegmentStopped: the timer was cleared but onSegmentStopped's
// mediaStream != null branch would still spawn a new segment. The
// fix nulls mediaStream FIRST. resetBuffer is the orthogonal
// mechanism for clearing the buffer (used by onUserStoppedSharing
// AND between tests). This test pins the contract that resetBuffer
// never leaves a partial segment behind.
pushSegmentForTest(makeSegmentBlob(0xa));
pushSegmentForTest(makeSegmentBlob(0xb));
pushSegmentForTest(makeSegmentBlob(0xc));
expect(getSegments()).toHaveLength(MAX_SEGMENTS);
resetBuffer();
expect(getSegments()).toHaveLength(0);
// Subsequent pushes start from an empty buffer — no eviction needed.
pushSegmentForTest(makeSegmentBlob(0xd));
expect(getSegments()).toHaveLength(1);
});
it('getSegments returns a snapshot — mutating the result does not corrupt internal state', () => { it('getSegments returns a snapshot — mutating the result does not corrupt internal state', () => {
pushSegmentForTest(makeSegmentBlob(0x1)); pushSegmentForTest(makeSegmentBlob(0x1));
pushSegmentForTest(makeSegmentBlob(0x2)); pushSegmentForTest(makeSegmentBlob(0x2));