diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts index 3551b7d..adb5370 100644 --- a/src/hooks/recorderHandle.test.ts +++ b/src/hooks/recorderHandle.test.ts @@ -90,6 +90,29 @@ describe("createRecorderHandle", () => { expect(decode(await blob.arrayBuffer())).toBe("abc"); }); + it("falls back to in-memory buffering when the open IPC call rejects", async () => { + const openRecordingStream = vi.fn(async () => { + throw new Error("ipc channel closed"); + }); + stubElectronAPI({ + openRecordingStream, + appendRecordingChunk: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); // open rejects -> treated as a failed open, keep buffering + fake.emit(new Blob(["b"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + expect(decode(await blob.arrayBuffer())).toBe("ab"); + }); + it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => { let releaseAppend: () => void = () => undefined; const appendGate = new Promise((resolve) => { @@ -142,6 +165,26 @@ describe("createRecorderHandle", () => { expect(handle.isStreaming()).toBe(false); }); + it("treats a rejected append the same as a failed write", async () => { + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => { + throw new Error("kernel said no"); + }), + closeRecordingStream: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + + await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/); + expect(handle.isStreaming()).toBe(false); + }); + it("buffers in memory and never opens a stream when no file name is given", async () => { const openRecordingStream = vi.fn(async () => ({ success: true })); stubElectronAPI({ diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index 3c63120..d547bf9 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -61,10 +61,17 @@ export function createRecorderHandle( if (appendError || !fileName || !api?.appendRecordingChunk) { return; } - const buffer = await chunk.arrayBuffer(); - const result = await api.appendRecordingChunk(fileName, buffer); - if (!result.success) { - appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + // Capture both outcomes — a `{ success: false }` result and an outright + // rejection (channel/handler error) — into appendError, so writeChain + // never rejects and isStreaming() stays consistent after a failure. + try { + const buffer = await chunk.arrayBuffer(); + const result = await api.appendRecordingChunk(fileName, buffer); + if (!result.success) { + appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + } + } catch (error) { + appendError = error instanceof Error ? error : new Error(String(error)); } }); }; @@ -74,18 +81,25 @@ export function createRecorderHandle( ? api.openRecordingStream(fileName) : Promise.resolve({ success: false }); - void openPromise.then((result) => { - if (result.success) { - streamOpened = true; - mode = "streaming"; - for (const chunk of memoryChunks) { - enqueueWrite(chunk); + void openPromise.then( + (result) => { + if (result.success) { + streamOpened = true; + mode = "streaming"; + for (const chunk of memoryChunks) { + enqueueWrite(chunk); + } + memoryChunks.length = 0; + } else { + mode = "buffering"; } - memoryChunks.length = 0; - } else { + }, + () => { + // The IPC call itself rejected (channel or handler error). Treat it the + // same as a failed open: keep buffering in memory so nothing is lost. mode = "buffering"; - } - }); + }, + ); const recordedBlobPromise = new Promise((resolve, reject) => { recorder.ondataavailable = (event: BlobEvent) => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d6c03f1..f5fb920 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -328,10 +328,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.setRecordingState(false); void (async () => { - // Set once the recording is safely stored. Until then any disk stream - // is still open, so the finally block closes it and removes the partial - // file on the discard or error paths. - let savedToDisk = false; + // Each disk stream must end up either saved or explicitly discarded. + // store-recorded-session finalizes the streams included in a successful + // save; the finally block discards everything else. + let storeSucceeded = false; + let webcamIncludedInSave = false; try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; if (discardRecordingId.current === activeRecordingId) { @@ -364,6 +365,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamVideoData = new ArrayBuffer(0); } } + webcamIncludedInSave = webcamVideoData !== undefined; const result = await window.electronAPI.storeRecordedSession({ screen: { @@ -383,8 +385,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.error("Failed to store recording session:", result.message); return; } - // store-recorded-session has flushed and closed the disk streams. - savedToDisk = true; + // store-recorded-session has flushed and closed the saved streams. + storeSucceeded = true; if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); @@ -396,12 +398,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { - if (!savedToDisk) { - // Discarded, or failed before a successful save — close any - // dangling disk streams and remove their partial files so a - // cancelled or failed run doesn't leak a descriptor or orphan. + // Discard any recorder whose data was not part of a successful save + // — a discarded run, a failed save, or a webcam whose disk write + // failed (so it was omitted while the screen still saved) — so no + // stream or partial file is left open or orphaned. + if (!storeSucceeded) { await activeScreenRecorder.discard().catch(() => undefined); - await activeWebcamRecorder?.discard().catch(() => undefined); + } + if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) { + await activeWebcamRecorder.discard().catch(() => undefined); } if (finalizingRecordingId.current === activeRecordingId) { finalizingRecordingId.current = null;