From b8ea8481186720d140db18b3524c35ca98a6cebe Mon Sep 17 00:00:00 2001 From: Yago Date: Sat, 6 Jun 2026 12:43:18 +0200 Subject: [PATCH 1/4] fix(recording): stream native-capture webcam to disk to prevent OOM crash on stop Native macOS/Windows capture recorded the webcam sidecar with an in-memory MediaRecorder (no fileName), buffering the whole clip in renderer memory and materializing it into one Blob/ArrayBuffer on stop. Long recordings (e.g. ~20 min at 18 Mbps, multi-GB) overflowed the renderer heap and crashed it with EXC_BREAKPOINT/SIGTRAP before the file was ever written, losing the entire take. The screen path already streams chunks to disk (#616); this extends the same mechanism to the native webcam sidecar: - Pass a fileName to the native webcam createRecorderHandle (mac + windows) so chunks are written to disk as they arrive instead of buffering in memory. - Thread the webcam fileName through the native recording state so finalize targets the exact file the recorder streamed to, independent of the native recordingId echoed back by the helper. - Windows finalize routes a streamed webcam through store-recorded-session with an empty buffer + durationMs (it already patches the WebM duration on disk). - macOS attach-native-mac-webcam-recording finalizes the streamed file via the recording-stream registry and patches its duration on disk, falling back to the in-memory buffer when not streamed. - Discard partial webcam files/streams on cancel, failure, or mid-stream write error so nothing is orphaned. The main process decides streamed-vs-memory by whether a disk stream is open (registry.finalize), so sending an empty buffer when streaming is safe and the in-memory fallback is preserved when the stream fails to open. Co-Authored-By: Claude Opus 4.8 --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 40 ++++++++++++--- electron/preload.ts | 1 + src/hooks/useScreenRecorder.ts | 94 +++++++++++++++++++++++++++++----- 4 files changed, 115 insertions(+), 21 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 18d44f1ae..9189ec6fe 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -155,6 +155,7 @@ interface Window { recordingId: number; webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index a7f41016c..b63a2e255 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -358,6 +358,12 @@ type AttachNativeMacWebcamRecordingInput = { recordingId?: number; webcam?: RecordedVideoAssetInput; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording duration in ms. Present when the webcam took the streaming path so + * its on-disk WebM (which lacks a Duration header) can be patched here, exactly + * as store-recorded-session patches streamed screen/webcam files. + */ + durationMs?: number; }; let selectedSource: SelectedSource | null = null; @@ -2149,6 +2155,14 @@ export function registerIpcHandlers( } }); + // On-disk write streams for in-progress recordings, keyed by output file name. + // Chunks are appended as they arrive from ondataavailable so the renderer + // never buffers the full video in memory (the #616 fix). Declared before the + // handlers that consume it (attach-native-mac-webcam-recording finalizes a + // streamed webcam through this registry). + const recordingStreams = new RecordingStreamRegistry(); + registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + ipcMain.handle( "attach-native-mac-webcam-recording", async (_, payload: AttachNativeMacWebcamRecordingInput) => { @@ -2167,12 +2181,28 @@ export function registerIpcHandlers( await fs.access(screenVideoPath, fsConstants.R_OK); - if (!payload.webcam?.fileName || !payload.webcam.videoData) { + if (!payload.webcam?.fileName) { return { success: false, error: "Native macOS webcam attachment is missing video data." }; } const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + // Streamed webcam bytes are already on disk (appended chunk-by-chunk during + // recording, the #616 fix); finalize() closes the stream and keeps the file. + // Otherwise the renderer sent the whole clip in memory and we write it here. + const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName); + if (webcamStreamed) { + if (isValidDurationMs(payload.durationMs)) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); + } + } else { + if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) { + return { + success: false, + error: "Native macOS webcam attachment is missing video data.", + }; + } + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } const createdAt = typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId) @@ -2210,12 +2240,6 @@ export function registerIpcHandlers( }, ); - // On-disk write streams for in-progress recordings, keyed by output file name. - // Chunks are appended as they arrive from ondataavailable so the renderer - // never buffers the full video in memory (the #616 fix). - const recordingStreams = new RecordingStreamRegistry(); - registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); - ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); diff --git a/electron/preload.ts b/electron/preload.ts index 96cc831fd..0a7f30780 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -123,6 +123,7 @@ contextBridge.exposeInMainWorld("electronAPI", { recordingId: number; webcam: { fileName: string; videoData: ArrayBuffer }; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => { return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload); }, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index e0db20ed9..0104b1c80 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -79,12 +79,18 @@ type NativeWindowsRecordingHandle = { finalizing: boolean; paused: boolean; webcamRecorder: RecorderHandle | null; + // File name the webcam sidecar streams to on disk, captured at start so finalize + // targets the exact same file regardless of the native recordingId echoed back. + webcamFileName?: string; }; type NativeMacRecordingHandle = { recordingId: number; finalizing: boolean; paused: boolean; + // File name the webcam sidecar streams to on disk, captured at start so finalize + // targets the exact same file regardless of the native recordingId echoed back. + webcamFileName?: string; }; export function useScreenRecorder(): UseScreenRecorderReturn { @@ -469,30 +475,52 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const nativeScreenPath = result.session?.screenVideoPath ?? result.path; let storedSession = result.session; if (activeWebcamRecorder && nativeScreenPath) { + // Await the blob promise to drain in-flight chunk writes (and surface a + // mid-stream write error) even when streaming, where it resolves empty. const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + const webcamStreamed = activeWebcamRecorder.isStreaming(); const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath); - if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) { - const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + const hasWebcamData = webcamStreamed || (webcamBlob != null && webcamBlob.size > 0); + const canStore = hasWebcamData && screenRead.success && !!screenRead.data; + // Once store-recorded-session is called it owns the webcam's disk stream + // (it finalizes the file). Until then, any opened stream is ours to drop. + let storeOwnsWebcam = false; + if (canStore && screenRead.data) { + storeOwnsWebcam = true; const nativeScreenFileName = nativeScreenPath.split(/[\\/]/).pop() ?? `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; - const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + const webcamFileName = + activeNativeRecording.webcamFileName ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + // Streamed webcam bytes are already on disk; send an empty buffer and let + // the main process patch the WebM duration there (mirrors the screen path). + const webcamVideoData = webcamStreamed + ? new ArrayBuffer(0) + : await (await fixWebmDuration(webcamBlob as Blob, duration)).arrayBuffer(); const stored = await window.electronAPI.storeRecordedSession({ screen: { videoData: screenRead.data, fileName: nativeScreenFileName, }, webcam: { - videoData: await fixedWebcamBlob.arrayBuffer(), + videoData: webcamVideoData, fileName: webcamFileName, }, createdAt: activeNativeRecording.recordingId, cursorCaptureMode, + durationMs: duration, }); if (stored.success && stored.session) { storedSession = stored.session; } } + if (!storeOwnsWebcam) { + // Webcam never reached a store call (no usable data, missing screen file, + // or a mid-stream write error left isStreaming() false). Drop any partial + // file/stream so it isn't orphaned. No-op for an in-memory recorder. + await activeWebcamRecorder.discard().catch(() => undefined); + } } clearNativeRecordingState(); @@ -542,17 +570,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (activeWebcamRecorder.recorder.state !== "inactive") { activeWebcamRecorder.recorder.stop(); } + // Await the blob promise to drain in-flight chunk writes (and surface a + // mid-stream write error) even when streaming, where it resolves empty. const webcamBlob = await activeWebcamRecorder.recordedBlobPromise; + const webcamFileName = + activeNativeRecording.webcamFileName ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + if (activeWebcamRecorder.isStreaming()) { + // Streamed webcam bytes are already on disk; hand the attach step an + // empty buffer and let the main process patch the duration there. + return { videoData: new ArrayBuffer(0), fileName: webcamFileName }; + } if (!webcamBlob || webcamBlob.size === 0) { return undefined; } const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); return { videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + fileName: webcamFileName, }; } catch (error) { console.error("Failed to finalize native macOS webcam recording:", error); + // A streamed recorder that errored mid-flight left a partial file and an + // open disk stream; discard both so nothing is orphaned. + await activeWebcamRecorder.discard().catch(() => undefined); return undefined; } })(); @@ -570,12 +611,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const result = await window.electronAPI.stopNativeMacRecording(discard); const webcamAsset = await webcamAssetPromise; if (discard || result.discarded) { + // Streamed webcam left an open stream + partial file; drop it. + await activeWebcamRecorder?.discard().catch(() => undefined); clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native macOS recording:", result.error); toast.error(result.error ?? "Failed to stop native macOS recording"); + await activeWebcamRecorder?.discard().catch(() => undefined); activeNativeRecording.finalizing = false; return true; } @@ -586,6 +630,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId: activeNativeRecording.recordingId, webcam: webcamAsset, cursorCaptureMode, + // Lets the main process patch the WebM duration of a streamed webcam, + // whose bytes are on disk and so were never duration-fixed in memory. + durationMs: duration, }); if (attachResult.success) { result.session = attachResult.session; @@ -593,6 +640,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.error("Failed to attach native macOS webcam recording:", attachResult.error); toast.error(attachResult.error ?? "Failed to store webcam recording"); } + } else if (webcamAsset && activeWebcamRecorder?.isStreaming()) { + // Streamed webcam with no screen output to attach to: drop the partial + // file and close its stream so it isn't orphaned on disk. + await activeWebcamRecorder.discard().catch(() => undefined); } clearNativeRecordingState(); @@ -817,12 +868,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } } + const windowsWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; const browserWebcamRecorder = webcamEnabled && webcamStream.current - ? createRecorderHandle(webcamStream.current, { - mimeType: selectMimeType(), - videoBitsPerSecond: BITRATE_BASE, - }) + ? createRecorderHandle( + webcamStream.current, + { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }, + // Stream webcam chunks to disk instead of buffering the whole clip in + // renderer memory, so a long recording can't OOM-crash on stop (#616). + windowsWebcamFileName, + ) : null; if (webcamEnabled && !browserWebcamRecorder) { stopWebcamPreviewStream(); @@ -880,6 +938,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizing: false, paused: false, webcamRecorder: browserWebcamRecorder, + webcamFileName: browserWebcamRecorder ? windowsWebcamFileName : undefined, }; webcamRecorder.current = browserWebcamRecorder; accumulatedDurationMs.current = 0; @@ -928,6 +987,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id); const windowId = parseMacWindowIdFromSourceId(selectedSource.id); let nativeWebcamRecorder: RecorderHandle | null = null; + let macWebcamFileName: string | undefined; if (webcamEnabled) { if (!webcamReady.current) { await new Promise((resolve) => { @@ -947,10 +1007,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } if (webcamStream.current) { - nativeWebcamRecorder = createRecorderHandle(webcamStream.current, { - mimeType: selectMimeType(), - videoBitsPerSecond: BITRATE_BASE, - }); + macWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + nativeWebcamRecorder = createRecorderHandle( + webcamStream.current, + { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }, + // Stream webcam chunks to disk instead of buffering the whole clip in + // renderer memory, so a long recording can't OOM-crash on stop (#616). + macWebcamFileName, + ); } else { webcamAcquireId.current++; setWebcamEnabledState(false); @@ -1024,6 +1091,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId: result.recordingId, finalizing: false, paused: false, + webcamFileName: macWebcamFileName, }; webcamRecorder.current = nativeWebcamRecorder; accumulatedDurationMs.current = 0; From e264f52156b839054743835c8796782338cfe615 Mon Sep 17 00:00:00 2001 From: Yago Date: Sat, 6 Jun 2026 16:31:00 +0200 Subject: [PATCH 2/4] fix(recording): discard streamed native webcam on early-exit/abort and roll back orphan on attach failure Addresses review feedback on the native webcam disk-streaming change: now that the native webcam sidecar opens a main-process write stream, every path that bails out must close that stream and delete its partial *-webcam.webm, and the macOS attach handler must not leave a finalized file behind when it later fails. - Windows finalize: discard the webcam recorder on the discard/cancel branch and the stop-failure branch (mirrors the macOS finalize cleanup). - Native start aborts (Windows start failure; macOS countdown-cancel, start failure, and post-start cancel): discard the sidecar before bailing, since createRecorderHandle has already begun streaming to disk by then. - attach-native-mac-webcam-recording: track the finalized streamed file and unlink it in the catch block so a failure after finalize doesn't orphan a *-webcam.webm with no session pointing at it. Validated: tsc --noEmit clean, biome check clean, vitest 225/225 passing. Co-Authored-By: Claude Opus 4.8 --- electron/ipc/handlers.ts | 10 ++++++++++ src/hooks/useScreenRecorder.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 427f2418a..a85cfbfbd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2151,6 +2151,9 @@ export function registerIpcHandlers( ipcMain.handle( "attach-native-mac-webcam-recording", async (_, payload: AttachNativeMacWebcamRecordingInput) => { + // When a streamed webcam is finalized to disk but a later step throws, this + // holds its path so the catch can remove the orphaned file. + let streamedWebcamRollbackPath: string | undefined; try { if (process.platform !== "darwin") { return { success: false, error: "Native macOS webcam attachment requires macOS." }; @@ -2176,6 +2179,8 @@ export function registerIpcHandlers( // Otherwise the renderer sent the whole clip in memory and we write it here. const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName); if (webcamStreamed) { + // The file is now kept on disk; mark it so a later failure rolls it back. + streamedWebcamRollbackPath = webcamVideoPath; if (isValidDurationMs(payload.durationMs)) { await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); } @@ -2217,6 +2222,11 @@ export function registerIpcHandlers( }; } catch (error) { console.error("Failed to attach native macOS webcam recording:", error); + // A streamed webcam was already finalized to disk before this failure; + // remove the orphan so no stray *-webcam.webm lingers without a session. + if (streamedWebcamRollbackPath) { + await fs.unlink(streamedWebcamRollbackPath).catch(() => undefined); + } return { success: false, error: error instanceof Error ? error.message : String(error), diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3e2feed79..6b7880444 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -460,12 +460,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const result = await window.electronAPI.stopNativeWindowsRecording(discard); if (discard || result.discarded) { + // Streamed webcam left an open stream + partial file; drop it. + await activeWebcamRecorder?.discard().catch(() => undefined); clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native Windows recording:", result.error); toast.error(result.error ?? "Failed to stop native Windows recording"); + await activeWebcamRecorder?.discard().catch(() => undefined); activeNativeRecording.finalizing = false; return true; } @@ -927,6 +930,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ) { browserWebcamRecorder.recorder.stop(); } + // The sidecar may already be streaming to disk; drop the partial file + // and close its stream so a failed start doesn't orphan it. + await browserWebcamRecorder?.discard().catch(() => undefined); throw new Error(result.error ?? "Native Windows capture failed."); } @@ -1025,6 +1031,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar so a cancelled countdown can't orphan it. + await nativeWebcamRecorder?.discard().catch(() => undefined); return true; } const request: NativeMacRecordingRequest = { @@ -1074,12 +1082,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar so a failed start doesn't orphan it. + await nativeWebcamRecorder?.discard().catch(() => undefined); throw new Error(result.error ?? "Native macOS capture failed."); } if (!isCountdownRunActive(countdownRunToken)) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar before bailing on the cancelled run. + await nativeWebcamRecorder?.discard().catch(() => undefined); await window.electronAPI.stopNativeMacRecording(true); return true; } From 23b317567d00a973fad3bb9c51f31a0e59409c2b Mon Sep 17 00:00:00 2001 From: Yago Date: Sat, 6 Jun 2026 16:44:14 +0200 Subject: [PATCH 3/4] fix(recording): make webcam discard await pending stream open and gate Windows ownership on store success Second round of review feedback on the native webcam disk-streaming change: - recorderHandle.discard() now awaits the pending openRecordingStream() (and drains the write chain) before deciding whether to close. Previously an early discard() on a cancel/failed-start could no-op while the open was still pending, then the open would resolve, flip streamOpened=true and flush chunks to a file nothing would ever clean up. This is the root fix for every discard()-on-early-exit site added in the prior commit. - Native Windows finalize: only mark storeOwnsWebcam after storeRecordedSession() resolves successfully, and move the "not handed off" discard into a `finally`, so a throw in fixWebmDuration()/storeRecordedSession() still drops the partial streamed sidecar instead of leaking it. Validated: tsc --noEmit clean, biome check clean, vitest 225/225 passing. Co-Authored-By: Claude Opus 4.8 --- src/hooks/recorderHandle.ts | 7 ++++ src/hooks/useScreenRecorder.ts | 75 +++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index 6264b9be5..29437d858 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -133,6 +133,13 @@ export function createRecorderHandle( } async function discard(): Promise { + // Wait for a pending open to settle before deciding. Otherwise an open that + // resolves just after an early discard() would set streamOpened = true and + // flush chunks to a file this discard already skipped — orphaning the stream + // and partial file. Draining writeChain too keeps the close ordered after any + // flushed chunks. + await openPromise.catch(() => undefined); + await writeChain; if (streamOpened && fileName && api?.closeRecordingStream) { await api.closeRecordingStream(fileName); } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 6b7880444..f4962b2de 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -485,42 +485,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const canStore = hasWebcamData && screenRead.success && !!screenRead.data; // Once store-recorded-session is called it owns the webcam's disk stream // (it finalizes the file). Until then, any opened stream is ours to drop. + // store-recorded-session finalizes (and thus owns) the webcam disk stream + // only once it returns success. Mark ownership after that resolves, and + // discard in `finally` so a throw in fixWebmDuration/storeRecordedSession + // still drops the partial sidecar instead of leaking it. let storeOwnsWebcam = false; - if (canStore && screenRead.data) { - storeOwnsWebcam = true; - const nativeScreenFileName = - nativeScreenPath.split(/[\\/]/).pop() ?? - `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; - const webcamFileName = - activeNativeRecording.webcamFileName ?? - `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; - // Streamed webcam bytes are already on disk; send an empty buffer and let - // the main process patch the WebM duration there (mirrors the screen path). - const webcamVideoData = webcamStreamed - ? new ArrayBuffer(0) - : await (await fixWebmDuration(webcamBlob as Blob, duration)).arrayBuffer(); - const stored = await window.electronAPI.storeRecordedSession({ - screen: { - videoData: screenRead.data, - fileName: nativeScreenFileName, - }, - webcam: { - videoData: webcamVideoData, - fileName: webcamFileName, - }, - createdAt: activeNativeRecording.recordingId, - cursorCaptureMode, - durationMs: duration, - }); - if (stored.success && stored.session) { - storedSession = stored.session; + try { + if (canStore && screenRead.data) { + const nativeScreenFileName = + nativeScreenPath.split(/[\\/]/).pop() ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; + const webcamFileName = + activeNativeRecording.webcamFileName ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + // Streamed webcam bytes are already on disk; send an empty buffer and let + // the main process patch the WebM duration there (mirrors the screen path). + const webcamVideoData = webcamStreamed + ? new ArrayBuffer(0) + : await (await fixWebmDuration(webcamBlob as Blob, duration)).arrayBuffer(); + const stored = await window.electronAPI.storeRecordedSession({ + screen: { + videoData: screenRead.data, + fileName: nativeScreenFileName, + }, + webcam: { + videoData: webcamVideoData, + fileName: webcamFileName, + }, + createdAt: activeNativeRecording.recordingId, + cursorCaptureMode, + durationMs: duration, + }); + storeOwnsWebcam = stored.success; + if (stored.success && stored.session) { + storedSession = stored.session; + } + } + } finally { + if (!storeOwnsWebcam) { + // Webcam never reached a successful store (no usable data, missing screen + // file, a mid-stream write error, or store threw/returned failure). Drop + // any partial file/stream. No-op for an in-memory recorder. + await activeWebcamRecorder.discard().catch(() => undefined); } - } - if (!storeOwnsWebcam) { - // Webcam never reached a store call (no usable data, missing screen file, - // or a mid-stream write error left isStreaming() false). Drop any partial - // file/stream so it isn't orphaned. No-op for an in-memory recorder. - await activeWebcamRecorder.discard().catch(() => undefined); } } From f910a39ffb7819cfcac616badf3dd47641fb6b11 Mon Sep 17 00:00:00 2001 From: Yago Date: Sat, 6 Jun 2026 16:54:04 +0200 Subject: [PATCH 4/4] fix(recording): make RecorderHandle.discard() idempotent Several early-exit paths can each call discard() for the same recorder (e.g. the webcamAssetPromise catch plus an outer error handler), which would invoke closeRecordingStream twice for one file. The main process already tolerates this (unlink swallows ENOENT), but guard discard() with a synchronous `discarded` flag so repeated/concurrent calls are a clean no-op after the first. Validated: tsc --noEmit clean, biome check clean, vitest 225/225 passing. Co-Authored-By: Claude Opus 4.8 --- src/hooks/recorderHandle.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index 29437d858..9b916c215 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -132,7 +132,15 @@ export function createRecorderHandle( return new Blob(memoryChunks, { type: mimeType }); } + let discarded = false; async function discard(): Promise { + // Idempotent: several early-exit paths may each call discard() for the same + // recorder (e.g. an inner catch plus an outer error handler). Set the flag + // synchronously so concurrent or repeated calls don't double-close the stream. + if (discarded) { + return; + } + discarded = true; // Wait for a pending open to settle before deciding. Otherwise an open that // resolves just after an early discard() would set streamOpened = true and // flush chunks to a file this discard already skipped — orphaning the stream