fix: tighten streaming failure handling from re-review
Addresses the CodeRabbit + Codex re-review of the prior commit.
- Normalize a rejected append (channel/handler error, not just a
{ success: false } result) into appendError, so the write queue never
rejects and isStreaming() stays consistent after a failure (CodeRabbit).
- Handle a rejected open-stream IPC the same as a failed open: fall back
to in-memory buffering instead of leaving the recorder stuck "pending"
with an unhandled rejection (CodeRabbit).
- Discard a streamed webcam whose write failed even when the screen save
succeeds. The cleanup gate is now per-recorder, so a webcam omitted from
a successful screen-only save no longer leaks its stream and partial
file (Codex).
Adds tests for the rejected-append and rejected-open paths.
Verified: tsc --noEmit clean; biome clean; vitest 182/182.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void>((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({
|
||||
|
||||
@@ -61,11 +61,18 @@ export function createRecorderHandle(
|
||||
if (appendError || !fileName || !api?.appendRecordingChunk) {
|
||||
return;
|
||||
}
|
||||
// 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,7 +81,8 @@ export function createRecorderHandle(
|
||||
? api.openRecordingStream(fileName)
|
||||
: Promise.resolve({ success: false });
|
||||
|
||||
void openPromise.then((result) => {
|
||||
void openPromise.then(
|
||||
(result) => {
|
||||
if (result.success) {
|
||||
streamOpened = true;
|
||||
mode = "streaming";
|
||||
@@ -85,7 +93,13 @@ export function createRecorderHandle(
|
||||
} else {
|
||||
mode = "buffering";
|
||||
}
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// 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<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user