Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.

fix: stream recording chunks to disk to support recordings longer than 10 minutes#617

Closed
Amanuel2x wants to merge 1 commit into
siddharthvaddem:mainfrom
Amanuel2x:fix/streaming-chunks-to-disk
Closed

fix: stream recording chunks to disk to support recordings longer than 10 minutes#617
Amanuel2x wants to merge 1 commit into
siddharthvaddem:mainfrom
Amanuel2x:fix/streaming-chunks-to-disk

Conversation

@Amanuel2x

@Amanuel2x Amanuel2x commented May 19, 2026

Copy link
Copy Markdown

Fixes #616

Problem

Recordings longer than ~10 minutes silently fail to save. The entire recording was buffered in the renderer as a Blob[] array, then on stop fixWebmDuration() + .arrayBuffer() + Buffer.from() created 3–4 additional in-memory copies before the data was written to disk. For an 18-minute 1080p recording this exceeds 400–600 MB in the renderer process, causing a silent crash with no file saved and no error shown.

Solution

Open an fs.WriteStream on the main process the moment recording starts, and pipe each 1-second ondataavailable chunk directly to disk via two new IPC calls (open-recording-stream, append-recording-chunk). On stop the stream is closed and the session manifest is written — the renderer never holds more than a single 1-second chunk in memory.

A full in-memory fallback is preserved for cases where the IPC stream fails to open.

Changes

  • src/hooks/useScreenRecorder.tscreateRecorderHandle streams chunks to disk via IPC instead of accumulating them in Blob[]; stop path skips arrayBuffer() when streaming succeeded
  • electron/ipc/handlers.ts — adds open-recording-stream and append-recording-chunk handlers; storeRecordedSessionFiles closes the write stream instead of calling writeFile when streaming was used
  • electron/preload.ts — exposes the two new IPC calls
  • electron/electron-env.d.ts — type declarations for the two new IPC calls

Testing

  • tsc --noEmit — no type errors
  • vitest --run — 151/151 tests passing

Summary by CodeRabbit

Release Notes

  • New Features
    • Implemented streaming-based recording architecture that writes video data directly to disk instead of buffering in memory, improving performance and reducing memory consumption during recording sessions.

Review Change Stack

Recordings longer than ~10 minutes would silently fail to save. The
entire recording was held in memory as a Blob[] array, then on stop
fixWebmDuration() and arrayBuffer() created additional copies before
the data was sent over IPC to be written. For an 18+ minute 1080p
recording this meant 400-600MB being duplicated 3-4x in the renderer
process, exceeding Electron's memory limits and causing a silent crash
with no file written to disk.

Fix: open an fs.WriteStream on the main process as soon as recording
starts, and send each 1-second ondataavailable chunk directly to disk
via IPC instead of accumulating chunks in RAM. On stop, the stream is
closed and the session manifest is written — no in-memory blob needed.
A full in-memory fallback is preserved for environments where the IPC
stream is unavailable.
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements disk-backed recording streaming to prevent out-of-memory crashes for long recordings. Instead of buffering the entire WebM blob in renderer memory, chunks are now streamed directly to disk via IPC as they arrive, keeping memory usage constant regardless of recording duration.

Changes

Recording Stream Persistence

Layer / File(s) Summary
IPC Contract & Bridge
electron/electron-env.d.ts, electron/preload.ts
New renderer-callable IPC methods openRecordingStream(recordingId, fileName) and appendRecordingChunk(recordingId, chunk) are typed and exposed via preload bridge, returning { success, error? } promises.
Main Process Stream Handlers
electron/ipc/handlers.ts
activeWriteStreams map tracks per-recordingId write streams; new handlers open-recording-stream (creates fs write stream) and append-recording-chunk (writes ArrayBuffer chunks) support streaming video data directly to disk.
Session Storage with Stream Finalization
electron/ipc/handlers.ts
storeRecordedSessionFiles now closes active write streams keyed by createdAt (screen) and createdAt + 1 (webcam), falling back to buffer writes if streams weren't opened.
Renderer Streaming Implementation & Finalization
src/hooks/useScreenRecorder.ts
createRecorderHandle now accepts recordingId and fileName, opens an IPC stream on init, buffers chunks until ready, and appends subsequent chunks; returns a handle with streaming flag. finalizeRecording skips blob processing for streamed recordings and passes empty ArrayBuffer(0) placeholders to session storage.
Recording Call Site Updates
src/hooks/useScreenRecorder.ts
macOS webcam, browser screen, and browser webcam recording starts now pass recordingId and output fileName to createRecorderHandle, enabling streaming mode across all recording paths.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related PRs

  • siddharthvaddem/openscreen#352: Modifies recording session persistence in electron/ipc/handlers.ts around output path resolution; main PR adds streaming chunk handlers to the same area.
  • siddharthvaddem/openscreen#457: Updates src/hooks/useScreenRecorder.ts recording lifecycle with recordingId-based session/discard logic that overlaps with streaming implementation.

Suggested Reviewers

  • siddharthvaddem
  • FabLrc

Poem

🎬 chunks flow freely to disk,
renderer memory no longer at risk,
write streams open, append, and close—
long recordings now win, not lose. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly identifies the fix (streaming chunks) and the specific problem solved (supporting longer recordings), directly reflecting the main change in the changeset.
Description check ✅ Passed Description covers all required sections: problem statement, solution approach, detailed file-by-file changes, and testing results. Includes linked issue reference (#616) and type/test verification.
Linked Issues check ✅ Passed All coding objectives from #616 are met: renderer streams chunks via IPC instead of buffering [useScreenRecorder.ts], main process writes directly to disk [ipc/handlers.ts], fallback preserves in-memory behavior, and renderer holds only single 1-second chunks.
Out of Scope Changes check ✅ Passed All four files modified (electron-env.d.ts, ipc/handlers.ts, preload.ts, useScreenRecorder.ts) directly support the streaming-to-disk objective with no extraneous changes observed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/hooks/useScreenRecorder.ts (1)

117-119: 💤 Low value

nit: chunk append errors are silently ignored.

if appendRecordingChunk fails mid-recording (disk full, stream error), the promise rejection is swallowed by void. the recording continues but data is lost with no feedback.

lowkey risky for long recordings — at minimum consider logging failures so they're debuggable. could also set a flag to trigger fallback behavior.

Also applies to: 142-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScreenRecorder.ts` around lines 117 - 119, The code currently
uses "void window.electronAPI.appendRecordingChunk(recordingId, chunk)" inside
the pendingChunks loop (and similarly at lines ~142-143), which swallows promise
rejections and hides failures; update the logic in useScreenRecorder.ts to await
or handle appendRecordingChunk's promise and surface errors: wrap the per-chunk
calls (or the Promise.all of them) in try/catch or attach .catch handlers that
log the error (e.g., console.error or an existing logger) and set a recording
error flag/state (e.g., recordingFailed or setRecordingError) so the UI or
fallback behavior can react when appendRecordingChunk(recordingId, chunk) fails;
ensure you update both the pendingChunks loop and the later occurrence that uses
appendRecordingChunk.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/ipc/handlers.ts`:
- Around line 2081-2089: The handler that opens a write stream (using
resolveRecordingOutputPath, createWriteStream and activeWriteStreams with
recordingId) can fail with ENOENT if the recordings directory doesn't exist;
before creating the write stream call fs.mkdir (or fs.promises.mkdir) on the
parent directory of resolveRecordingOutputPath(fileName) with { recursive: true
} (await if using promises) inside the try block, then create the stream and set
activeWriteStreams; keep the existing try/catch and return shape but ensure the
directory creation is awaited and errors propagate into the existing error
return.
- Around line 2130-2141: The finalize path leaks file streams when a recording
is discarded: update finalizeScreenRecording (and/or cancelRecording flow) to
explicitly close and cleanup any activeWriteStreams entry for the recordingId
(use the same logic as in the existing write/close block that calls screenWs.end
and activeWriteStreams.delete) before bailing out, and also remove any partial
screenVideoPath (or ensure stream end is awaited) so open handles and orphaned
.webm files are cleaned up; reference activeWriteStreams,
finalizeScreenRecording, cancelRecording, discardRecordingId.current,
storeRecordedSession and discardCursorTelemetry when adding this cleanup or
creating a dedicated IPC abortRecording handler.

In `@src/hooks/useScreenRecorder.ts`:
- Around line 104-166: The returned streaming flag is captured before
streamOpenPromise resolves, causing a race and data loss; fix by determining
streaming only when recording stops and exposing that final value (or including
it with the resolved blob). Concretely, update recorder.onstop to set a
finalStreaming boolean (based on streamFailed) before resolving
recordedBlobPromise, and change the hook's return value to either provide a
getter/function for streaming or have recordedBlobPromise resolve to an object
like { blob, streaming }; then update finalizeRecording to consume the final
streaming state from that getter/object instead of reading the early-captured
streaming variable. Ensure you touch streamOpenPromise,
streamFailed/streamReady, recorder.onstop, recordedBlobPromise, and the place
that checks activeScreenRecorder.streaming in finalizeRecording.

---

Nitpick comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 117-119: The code currently uses "void
window.electronAPI.appendRecordingChunk(recordingId, chunk)" inside the
pendingChunks loop (and similarly at lines ~142-143), which swallows promise
rejections and hides failures; update the logic in useScreenRecorder.ts to await
or handle appendRecordingChunk's promise and surface errors: wrap the per-chunk
calls (or the Promise.all of them) in try/catch or attach .catch handlers that
log the error (e.g., console.error or an existing logger) and set a recording
error flag/state (e.g., recordingFailed or setRecordingError) so the UI or
fallback behavior can react when appendRecordingChunk(recordingId, chunk) fails;
ensure you update both the pendingChunks loop and the later occurrence that uses
appendRecordingChunk.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1f44ef88-365b-4fb5-b52e-82313de502a5

📥 Commits

Reviewing files that changed from the base of the PR and between 00fbd95 and 2f60df5.

📒 Files selected for processing (4)
  • electron/electron-env.d.ts
  • electron/ipc/handlers.ts
  • electron/preload.ts
  • src/hooks/useScreenRecorder.ts

Comment thread electron/ipc/handlers.ts
Comment on lines +2081 to +2089
try {
const filePath = resolveRecordingOutputPath(fileName);
const ws = createWriteStream(filePath, { flags: "w" });
activeWriteStreams.set(recordingId, ws);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing directory creation before opening write stream.

createWriteStream will throw ENOENT if RECORDINGS_DIR doesn't exist. Native recording flows call fs.mkdir(RECORDINGS_DIR, { recursive: true }) before starting, but the browser-based recording flow doesn't — it relies on this handler being called first.

🐛 Proposed fix
 	ipcMain.handle(
 		"open-recording-stream",
 		async (
 			_,
 			recordingId: number,
 			fileName: string,
 		): Promise<{ success: boolean; error?: string }> => {
 			try {
+				await fs.mkdir(RECORDINGS_DIR, { recursive: true });
 				const filePath = resolveRecordingOutputPath(fileName);
 				const ws = createWriteStream(filePath, { flags: "w" });
 				activeWriteStreams.set(recordingId, ws);
 				return { success: true };
 			} catch (error) {
 				return { success: false, error: String(error) };
 			}
 		},
 	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const filePath = resolveRecordingOutputPath(fileName);
const ws = createWriteStream(filePath, { flags: "w" });
activeWriteStreams.set(recordingId, ws);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
const filePath = resolveRecordingOutputPath(fileName);
const ws = createWriteStream(filePath, { flags: "w" });
activeWriteStreams.set(recordingId, ws);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 2081 - 2089, The handler that opens a
write stream (using resolveRecordingOutputPath, createWriteStream and
activeWriteStreams with recordingId) can fail with ENOENT if the recordings
directory doesn't exist; before creating the write stream call fs.mkdir (or
fs.promises.mkdir) on the parent directory of
resolveRecordingOutputPath(fileName) with { recursive: true } (await if using
promises) inside the try block, then create the stream and set
activeWriteStreams; keep the existing try/catch and return shape but ensure the
directory creation is awaited and errors propagate into the existing error
return.

Comment thread electron/ipc/handlers.ts
Comment on lines +2130 to +2141

// Close the streaming write stream if one was used; otherwise fall back to
// writing the full buffer (short recordings that never opened a stream).
const screenWs = activeWriteStreams.get(createdAt);
if (screenWs) {
await new Promise<void>((resolve, reject) =>
screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())),
);
activeWriteStreams.delete(createdAt);
} else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) {
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if discardCursorTelemetry is the only cleanup called on discard
rg -n "discardCursorTelemetry" --type ts -C 3

Repository: siddharthvaddem/openscreen

Length of output: 1541


🏁 Script executed:

#!/bin/bash
# Find the discard-cursor-telemetry handler in electron handlers
rg -n "discard-cursor-telemetry" --type ts -A 15 electron/

Repository: siddharthvaddem/openscreen

Length of output: 1152


🏁 Script executed:

#!/bin/bash
# Also check what happens with activeWriteStreams when recording is discarded
rg -n "activeWriteStreams" --type ts -B 3 -A 3

Repository: siddharthvaddem/openscreen

Length of output: 3504


🏁 Script executed:

#!/bin/bash
# Check if there's a cancel/cleanup handler for recordings
rg -n "cancelRecording\|abort.*[Rr]ecord" --type ts -A 5

Repository: siddharthvaddem/openscreen

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Search for the discard-cursor-telemetry handler definition
rg -n '"discard-cursor-telemetry"' electron/ipc/handlers.ts -A 20

Repository: siddharthvaddem/openscreen

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Also check the structure around the storeRecordedSessionFiles handler to understand the flow better
rg -n '"store-recorded-session"' electron/ipc/handlers.ts -B 5 -A 3

Repository: siddharthvaddem/openscreen

Length of output: 313


🏁 Script executed:

#!/bin/bash
# Search for discard-cursor-telemetry in all files
rg -n '"discard-cursor-telemetry"' . --type ts

Repository: siddharthvaddem/openscreen

Length of output: 166


🏁 Script executed:

#!/bin/bash
# Check the useScreenRecorder hook to see full flow on discard
rg -n "discardRecordingId.current" src/hooks/useScreenRecorder.ts -B 5 -A 10

Repository: siddharthvaddem/openscreen

Length of output: 5815


Streams definitely leak when recording is cancelled/discarded.

When cancelRecording is called, it sets discardRecordingId.current and triggers finalization. In finalizeScreenRecording, if the recording is being discarded (line 416-418 in useScreenRecorder.ts), it bails out early without calling storeRecordedSession. That handler is the only place activeWriteStreams gets cleaned up—so the write streams stay open indefinitely, and partial .webm files are orphaned on disk.

The discardCursorTelemetry call looks like an incomplete attempt at cleanup; the handler doesn't even exist in the codebase.

You'll need either:

  1. A dedicated IPC to abort/cleanup a recording stream by recordingId, or
  2. Close streams directly when discard happens (maybe in a new handler or as part of the same finalize flow)

Without this, cancelled recordings will accumulate open file handles and disk clutter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 2130 - 2141, The finalize path leaks
file streams when a recording is discarded: update finalizeScreenRecording
(and/or cancelRecording flow) to explicitly close and cleanup any
activeWriteStreams entry for the recordingId (use the same logic as in the
existing write/close block that calls screenWs.end and
activeWriteStreams.delete) before bailing out, and also remove any partial
screenVideoPath (or ensure stream end is awaited) so open handles and orphaned
.webm files are cleaned up; reference activeWriteStreams,
finalizeScreenRecording, cancelRecording, discardRecordingId.current,
storeRecordedSession and discardCursorTelemetry when adding this cleanup or
creating a dedicated IPC abortRecording handler.

Comment on lines +104 to +166

// Try to open a disk-backed stream. Falls back to in-memory if IPC unavailable.
const streamOpenPromise =
window.electronAPI?.openRecordingStream?.(recordingId, fileName) ??
Promise.resolve({ success: false });

const pendingChunks: ArrayBuffer[] = [];
let streamReady = false;
let streamFailed = false;

streamOpenPromise.then((result) => {
if (result.success) {
streamReady = true;
for (const chunk of pendingChunks) {
void window.electronAPI.appendRecordingChunk(recordingId, chunk);
}
pendingChunks.length = 0;
} else {
streamFailed = true;
}
});

const fallbackChunks: Blob[] = [];

const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
if (!event.data || event.data.size === 0) return;

if (streamFailed) {
fallbackChunks.push(event.data);
return;
}

void event.data.arrayBuffer().then((buf) => {
if (streamFailed) {
fallbackChunks.push(new Blob([buf], { type: mimeType }));
return;
}
if (streamReady) {
void window.electronAPI.appendRecordingChunk(recordingId, buf);
} else {
pendingChunks.push(buf);
}
});
};

recorder.onerror = () => {
reject(new Error("Recording failed"));
};

recorder.onstop = () => {
resolve(new Blob(chunks, { type: mimeType }));
if (streamFailed) {
// Streaming failed — return full in-memory blob as fallback.
resolve(new Blob(fallbackChunks, { type: mimeType }));
} else {
// Streaming succeeded — main process already has the data on disk.
resolve(new Blob([], { type: mimeType }));
}
};
});

recorder.start(RECORDER_TIMESLICE_MS);
return { recorder, recordedBlobPromise };
return { recorder, recordedBlobPromise, streaming: !streamFailed };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Race condition: streaming flag doesn't reflect actual outcome.

The streaming flag is captured at line 166 as !streamFailed, but at that point the streamOpenPromise hasn't resolved yet — so streamFailed is always false and streaming is always true initially.

If the IPC stream fails to open (permissions, disk error, etc.), the fallback logic in ondataavailable correctly buffers to fallbackChunks, and onstop correctly returns the fallback blob. But then in finalizeRecording, the check at line 430:

if (!activeScreenRecorder.streaming && screenBlob.size > 0) {

...is false because streaming was captured as true. The in-memory data gets thrown away and an empty ArrayBuffer(0) is sent instead, resulting in data loss.

kinda cursed timing issue — maybe make streaming a getter that checks the final state, or resolve the promise before returning.

🐛 One possible fix: defer flag evaluation to onstop
-	const fallbackChunks: Blob[] = [];
+	let usedFallback = false;
+	const fallbackChunks: Blob[] = [];

 	const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
 		recorder.ondataavailable = (event: BlobEvent) => {
 			if (!event.data || event.data.size === 0) return;

 			if (streamFailed) {
 				fallbackChunks.push(event.data);
+				usedFallback = true;
 				return;
 			}

 			void event.data.arrayBuffer().then((buf) => {
 				if (streamFailed) {
 					fallbackChunks.push(new Blob([buf], { type: mimeType }));
+					usedFallback = true;
 					return;
 				}
 				// ... rest unchanged
 			});
 		};

 		recorder.onstop = () => {
-			if (streamFailed) {
+			if (usedFallback) {
 				resolve(new Blob(fallbackChunks, { type: mimeType }));
 			} else {
 				resolve(new Blob([], { type: mimeType }));
 			}
 		};
 	});

 	recorder.start(RECORDER_TIMESLICE_MS);
-	return { recorder, recordedBlobPromise, streaming: !streamFailed };
+	// streaming is determined by whether we fell back to in-memory
+	// We need to expose this info; simplest: check blob size in finalizeRecording
+	// OR return a promise/getter. For now, returning true and relying on blob.size check.
+	return { recorder, recordedBlobPromise, streaming: true };

Then update finalizeRecording to check blob size regardless of streaming flag:

-if (!activeScreenRecorder.streaming && screenBlob.size === 0) {
+if (screenBlob.size === 0 && !activeScreenRecorder.streaming) {
   return;
 }
+// If blob has data, always process it (fallback case)
+let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
+if (screenBlob.size > 0) {
+  const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
+  screenVideoData = await fixedScreenBlob.arrayBuffer();
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScreenRecorder.ts` around lines 104 - 166, The returned
streaming flag is captured before streamOpenPromise resolves, causing a race and
data loss; fix by determining streaming only when recording stops and exposing
that final value (or including it with the resolved blob). Concretely, update
recorder.onstop to set a finalStreaming boolean (based on streamFailed) before
resolving recordedBlobPromise, and change the hook's return value to either
provide a getter/function for streaming or have recordedBlobPromise resolve to
an object like { blob, streaming }; then update finalizeRecording to consume the
final streaming state from that getter/object instead of reading the
early-captured streaming variable. Ensure you touch streamOpenPromise,
streamFailed/streamReady, recorder.onstop, recordedBlobPromise, and the place
that checks activeScreenRecorder.streaming in finalizeRecording.

@neurot1cal neurot1cal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — streaming chunks to disk

Great diagnosis and the right architecture (timeslice → disk, renderer holds one chunk). Left inline comments on the specific lines. The short version: the change trades the current loud failure for a few silent ones, including a metadata regression on the exact long recordings it's meant to save. I'd treat the three inline items marked blocking as merge-blockers.

Good parts: correct root cause; in-memory fallback preserved when the IPC stream fails to open; types added on both sides; tsc --noEmit clean.

Testing this locally

tsc + vitest don't cover the risk surface here (ordering, duration, cleanup). Suggested manual checks:

  1. Duration regression: record ~12–15 min @ 1080p, stop, then ffprobe -v error -show_entries format=duration -of csv=p=0 <out>.webm — expect a real number; today a streamed file will likely show N/A/wrong. Scrub the seek bar to confirm.
  2. Ordering: record a long high-motion clip and play end-to-end looking for freezes; better, add a seq to append-recording-chunk and assert monotonic arrival.
  3. Abandon/leak: start a recording then quit/cancel mid-way — confirm no half-file remains and activeWriteStreams.size === 0; lsof -p <main-pid> | grep .webm shows no lingering handle.
  4. Disk-full: point output at a tiny volume and record past capacity — expect a surfaced error, not a silent truncated file.
  5. Fallback path: force openRecordingStream to return { success: false } and confirm the in-memory path still yields a correct, duration-fixed file.

🤖 Thorough code-quality pass via Claude Code.


// Only fix duration / convert to ArrayBuffer if we have in-memory data.
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
if (!activeScreenRecorder.streaming && screenBlob.size > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] fixWebmDuration is skipped for every streamed recording → broken duration metadata.

Raw MediaRecorder WebM has no usable duration header — that's why fixWebmDuration exists. Streamed files now bypass it, so the long recordings this PR fixes save with N/A/infinite duration: no reliable seek bar, wrong length downstream. The crash is gone, but the file looks corrupt.

Fix: repair duration on the file after the stream closes, in the main process where the bytes now live. handlers.ts already imports spawn and the app ships ffmpeg, so a -c copy remux / duration-rewrite on close is the natural home (the renderer can't do it anymore — it no longer holds the blob).

return;
}

void event.data.arrayBuffer().then((buf) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Chunk write order isn't guaranteed → intermittent corruption.

Blob.arrayBuffer() resolution order across separate calls isn't spec-guaranteed, and the append is fire-and-forget (void). Chunk N+1 can resolve and append before chunk N — for a sequential container that's an unplayable file, intermittently, with no error.

Fix: make ordering explicit — serialize through one queue (await chunk K's arrayBuffer() + append before K+1), or pass a monotonic seq and have the main side assert/order on it.

Related: the three-state handling here (streamFailed / streamReady / pendingChunks, two booleans + an array + a nested streamFailed re-check) is a small state machine bolted into the callback. A tiny ChunkSink (accept(chunk) that buffers-until-ready / appends / falls back) would collapse the branching and is the natural home for the ordering fix.

Comment thread electron/ipc/handlers.ts
// Streaming chunk writers — keyed by recordingId. Chunks are appended directly
// to disk as they arrive from ondataavailable so the renderer never holds the
// full video in memory.
const activeWriteStreams = new Map<number, WriteStream>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] WriteStream lifecycle leaks.

activeWriteStreams is only .deleted inside storeRecordedSessionFiles. Any abandon path — onerror, user discard, early return, crash before stop — leaves the stream open (leaked fd) with a half-written file on disk. Opening a second stream for the same id would also orphan the first.

Fix: wrap this in one owner (RecordingStreams with open / append / close / abort) that closes on every terminal path, and guard against double-open.

Comment thread electron/ipc/handlers.ts

// Close the streaming write stream if one was used; otherwise fall back to
// writing the full buffer (short recordings that never opened a stream).
const screenWs = activeWriteStreams.get(createdAt);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Open/close keys can diverge → empty file + leaked stream.

Streams are opened keyed by recordingId but closed here by createdAt, which falls back to Date.now() above. If payload.createdAt is ever falsy, createdAt becomes a fresh timestamp, this get misses, and you fall through to fs.writeFile(path, Buffer.from(emptyArrayBuffer)) — an empty file, with the real stream never closed. The renderer passes them equal today, but that's an undocumented cross-call invariant.

Fix: key open and close by the same single id and drop the Date.now() fallback for the streaming key.

Comment thread electron/ipc/handlers.ts
const ws = activeWriteStreams.get(recordingId);
if (!ws) return { success: false, error: "No active stream for recordingId " + recordingId };
return new Promise((resolve) => {
ws.write(Buffer.from(chunk), (err) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disk-write failures are swallowed → silent truncation.

This returns { success: false } on a ws.write error, but the renderer voids the call and never reads it. Disk-full mid-recording silently drops every later chunk and the recording "succeeds" truncated — the same silent-loss mode this PR is removing, relocated.

Fix: propagate the first append failure back to the recorder (stop + user-visible error, or fall back to in-memory if still viable). Also consider backpressure — ws.write's return value is ignored here, so a high-bitrate recording can balloon the main process buffer; respect write() === false / drain.

Comment thread electron/ipc/handlers.ts
if (payload.webcam) {
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordingId + 1 as the webcam key is brittle magic.

This + 1 convention is undocumented numeric coupling between renderer (activeRecordingId + 1) and main (createdAt + 1). Prefer explicit keys like ${id}:screen / ${id}:webcam, or return a real stream handle from open so callers never compute keys.

@neurot1cal

neurot1cal commented May 24, 2026

Copy link
Copy Markdown
Contributor

Testing follow-up — 16-min recording + duration-patch fix

Built 2f60df5 on macOS 15.7.5 (Apple Silicon, no Xcode → no Swift helper) and ran end-to-end. Three local test bypasses worked around TCC issues with the unsigned dev Electron.app (skip Swift-helper-only native-mac path, skip the in-app screen-recording dialog gate, skip the cursor-Accessibility preflight) — none touched the streaming code path.

What worked

  • npm test 151/151 green on PR head.
  • Streaming clearly works on the happy path. The .webm started growing on disk within ~5s of pressing record, sustained ~30 MB/min, ended at 557 MB after ~16 min. Pre-PR this would never have hit disk before stop. No appendRecordingChunk rejections in the dev log. Per-minute samples:
    +0:00   32M
    +1:00   53M
    +3:00   111M
    +6:00   276M
    +9:00   373M
    +12:00  421M
    +stop   557M
    
  • The editor opened automatically when stop was pressed, with the file loaded as the preview source. The renderer survives, storeRecordedSession runs, the file is reachable. Headline win confirmed.

Confirmed: finding #2 is real

$ ffprobe -v error -show_entries format -of default=noprint_wrappers=1 recording-1779652213304.webm
format_name=matroska,webm
start_time=0.000000
duration=N/A
size=584327464
bit_rate=N/A
TAG:encoder=Chrome

duration=N/A, bit_rate=N/A. The 557 MB file plays in the preview frame but every control that needs a timeline is broken: the player reads 0:03 / 0:00, the timeline panel below it shows "No Video Loaded", and any scrub/seek/trim relies on a known duration. Textbook symptom of a Matroska/WebM with the duration EBML element unwritten — decoders walk frames sequentially fine, but cursor-position-to-frame mapping has nothing to map against.

User-felt failure mode: pre-PR they got nothing on stop; post-PR they get a 557 MB file that loads in the preview but is uneditable.

Fix (diff below)

Patch the WebM Duration header on disk in the main process after WriteStream.end() resolves, since the renderer no longer holds the blob to run fixWebmDuration on. The existing @fix-webm-duration dependency exposes a parser-level API (fixParsedWebmDuration(WebmFile, durationMs)) that operates on a Uint8Array, which works fine in Node. Best-effort: a patch failure logs and returns rather than blocking the editor — the file is still playable, just with broken controls if patching fails.

Verified locally: same 16-min recording with the patch applied → ffprobe reports duration=90.862 on a 90s test, editor seek bar shows 0:00 / 1:30, timeline populated, zoom blocks land on real timestamps. npx tsc --noEmit clean, npm test 151/151 still green.

duration-patch diff (3 files, applies on 2f60df5)
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 15f6138..0937ffd 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -5,6 +5,8 @@ import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 import { fileURLToPath, pathToFileURL } from "node:url";
+import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
+import { WebmFile } from "@fix-webm-duration/parser";
 import type { DesktopCapturerSource } from "electron";
 import {
 	app,
@@ -1217,6 +1219,43 @@ async function loadRecordedSessionForVideoPath(
 	}
 }
 
+/**
+ * Patch the WebM Duration header on a finalized recording file.
+ *
+ * Browser MediaRecorder writes WebM with no Duration EBML element. With the
+ * streaming-to-disk path, the renderer never holds the blob, so the historical
+ * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after
+ * `WriteStream.end()` produces an equivalent result: the editor's seek bar and
+ * timeline read a real duration instead of `N/A`.
+ *
+ * Best-effort by design — the file is still playable without the patch (decoders
+ * walk frames sequentially), so a failure here logs and returns rather than
+ * surfacing as an error to the user. Reads the whole file into a Buffer in the
+ * main process; that's the same memory profile as the pre-PR renderer path,
+ * just on the side that doesn't have V8's heap cap.
+ */
+async function patchWebmDurationOnDisk(filePath: string, durationMs: number): Promise<void> {
+	try {
+		const fileBytes = await fs.readFile(filePath);
+		const webm = new WebmFile(new Uint8Array(fileBytes));
+		const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
+		if (!patched) {
+			// Either no Segment/Info section, or the Duration field was already valid.
+			// Both cases are fine — log at debug level and move on.
+			console.debug(`[patchWebmDurationOnDisk] no patch applied to ${filePath}`);
+			return;
+		}
+		if (!webm.source) {
+			console.error(`[patchWebmDurationOnDisk] patched but source missing for ${filePath}`);
+			return;
+		}
+		const patchedBytes = Buffer.from(webm.source.buffer, webm.source.byteOffset, webm.source.byteLength);
+		await fs.writeFile(filePath, patchedBytes);
+	} catch (error) {
+		console.error(`[patchWebmDurationOnDisk] failed to patch ${filePath}:`, error);
+	}
+}
+
 export function registerIpcHandlers(
 	createEditorWindow: () => void,
 	createSourceSelectorWindow: () => BrowserWindow,
@@ -2131,16 +2170,19 @@ export function registerIpcHandlers(
 		// Close the streaming write stream if one was used; otherwise fall back to
 		// writing the full buffer (short recordings that never opened a stream).
 		const screenWs = activeWriteStreams.get(createdAt);
+		let screenStreamed = false;
 		if (screenWs) {
 			await new Promise<void>((resolve, reject) =>
 				screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())),
 			);
 			activeWriteStreams.delete(createdAt);
+			screenStreamed = true;
 		} else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) {
 			await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
 		}
 
 		let webcamVideoPath: string | undefined;
+		let webcamStreamed = false;
 		if (payload.webcam) {
 			webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
 			const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1
@@ -2149,11 +2191,33 @@ export function registerIpcHandlers(
 					webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())),
 				);
 				activeWriteStreams.delete(createdAt + 1);
+				webcamStreamed = true;
 			} else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) {
 				await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
 			}
 		}
 
+		// Streamed files lack the WebM Duration header (renderer no longer holds the
+		// blob to patch). Patch on disk so the editor's seek bar and timeline work.
+		// Best-effort: log on failure but don't block, since the file is still playable.
+		if (
+			screenStreamed &&
+			typeof payload.durationMs === "number" &&
+			Number.isFinite(payload.durationMs) &&
+			payload.durationMs > 0
+		) {
+			await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs);
+		}
+		if (
+			webcamStreamed &&
+			webcamVideoPath &&
+			typeof payload.durationMs === "number" &&
+			Number.isFinite(payload.durationMs) &&
+			payload.durationMs > 0
+		) {
+			await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs);
+		}
+
 		const session: RecordingSession = webcamVideoPath
 			? {
 					screenVideoPath,
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 2d2147b..711b28b 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -454,6 +454,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
 								: undefined,
 						createdAt: activeRecordingId,
 						cursorCaptureMode,
+						durationMs: duration,
 					});
 
 					if (!result.success) {
diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts
index f5ebf9c..12a6afd 100644
--- a/src/lib/recordingSession.ts
+++ b/src/lib/recordingSession.ts
@@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput {
 	webcam?: RecordedVideoAssetInput;
 	createdAt?: number;
 	cursorCaptureMode?: CursorCaptureMode;
+	/**
+	 * Recording wall-clock duration in milliseconds. Used by the main process
+	 * to patch the WebM Duration header on streamed recordings, since the
+	 * renderer no longer holds the bytes. Browser MediaRecorder writes WebM
+	 * with no/zero duration; without this patch, the editor's seek bar and
+	 * timeline break for any recording that took the streaming path.
+	 */
+	durationMs?: number;
 }
 
 export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {

Happy to turn this into a PR against the branch if that's easier than applying by hand. Open to a different shape too — head-only patch (~50 ms, no full-file read) or an ffmpeg -c copy remux are both viable; I went with the smallest-code version. The current patch reads the whole file into a main-process Buffer on stop, which adds ~1–5 s depending on size.

What I didn't exercise this run

Bottom line

The PR delivers what it claims for the success path. The duration-metadata regression is real and now has a hard receipt, plus a diff that fixes it.

🤖 Local repro + thorough code-quality pass via Claude Code.

@siddharthvaddem

Copy link
Copy Markdown
Owner

@Amanuel2x can you address above findings and concerns raised?

siddharthvaddem pushed a commit that referenced this pull request May 27, 2026
Recordings longer than ~10 minutes silently fail to save (#616). The
renderer buffers the whole WebM as a Blob[], then on stop makes several
in-memory copies (fixWebmDuration -> arrayBuffer -> Buffer.from) before
writing. A long 1080p recording duplicates hundreds of MB several times
in the renderer, exceeds Electron's memory limit, and the renderer
crashes silently with no file saved.

Two changes:

1. Stream chunks to disk (originally @Amanuel2x's contribution in #617).
   Open an fs.WriteStream in the main process at recording start and send
   each ~1s ondataavailable chunk straight to disk over two new IPC calls
   (open-recording-stream, append-recording-chunk), so the renderer never
   holds more than a single chunk. A full in-memory fallback is preserved
   for environments where the IPC stream cannot open.

2. Patch the WebM Duration header on disk after the stream closes. Browser
   MediaRecorder writes WebM with no Duration element, so streamed files
   save with duration=N/A and the editor's seek bar, timeline, and any
   scrub/trim break. A new electron/recording/webm-duration.ts module
   rewrites the Duration element, writing to a temp file and renaming in
   place so a crash mid-write cannot corrupt the recording.

Streaming is opt-in: the screen recorder and the browser-only webcam
recorder stream to disk; native-capture webcam sidecars (Windows, macOS)
keep buffering in-memory, since their finalize path reads the recorder
blob directly to attach the webcam track.

Verified: tsc --noEmit clean; biome clean; vitest 166/166.

Closes #616
Supersedes #617

Co-Authored-By: Amanuel <amanuel@localboostnetworking.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@siddharthvaddem

Copy link
Copy Markdown
Owner

@Amanuel2x closing this in favour or #658 - credits given by author.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recordings longer than ~10 minutes silently fail to save

3 participants