Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -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,20 +255,50 @@ function startNewSegment(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentChunks = [];
|
currentChunks = [];
|
||||||
videoRecorder = new MediaRecorder(mediaStream, {
|
try {
|
||||||
mimeType: VIDEO_MIME,
|
videoRecorder = new MediaRecorder(mediaStream, {
|
||||||
videoBitsPerSecond: VIDEO_BITRATE,
|
mimeType: VIDEO_MIME,
|
||||||
});
|
videoBitsPerSecond: VIDEO_BITRATE,
|
||||||
videoRecorder.ondataavailable = onDataAvailable;
|
});
|
||||||
videoRecorder.onstop = onSegmentStopped;
|
videoRecorder.ondataavailable = onDataAvailable;
|
||||||
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
|
videoRecorder.onstop = onSegmentStopped;
|
||||||
// Без timeslice: одно событие dataavailable придёт по .stop() — это
|
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
|
||||||
// ровно один blob, содержащий целиком сегмент (EBML-заголовок +
|
// Без timeslice: одно событие dataavailable придёт по .stop() — это
|
||||||
// кластеры). Так каждый сегмент гарантированно декодируется
|
// ровно один blob, содержащий целиком сегмент (EBML-заголовок +
|
||||||
// независимо. Если когда-то потребуется живая стат-телеметрия,
|
// кластеры). Так каждый сегмент гарантированно декодируется
|
||||||
// можно дать timeslice назад без изменения семантики ротации.
|
// независимо. Если когда-то потребуется живая стат-телеметрия,
|
||||||
videoRecorder.start();
|
// можно дать timeslice назад без изменения семантики ротации.
|
||||||
scheduleRotation();
|
videoRecorder.start();
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user