This repository was archived by the owner on Jun 17, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Fix: stream long recordings to disk and patch WebM duration on save (#616) #658
Merged
siddharthvaddem
merged 4 commits into
siddharthvaddem:main
from
neurot1cal:fix/streaming-recording-duration
May 27, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
727e395
fix: stream long recordings to disk and patch WebM duration on save
neurot1cal f3c5b8a
fix: harden streaming lifecycle and lift it out of the IPC god-module
neurot1cal 36d7d2b
fix: tighten streaming failure handling from re-review
neurot1cal 5c5cab6
fix: don't stream when the append IPC is unavailable
neurot1cal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { RecordingStreamRegistry } from "./recordingStream"; | ||
|
|
||
| describe("RecordingStreamRegistry", () => { | ||
| let dir: string; | ||
| const pathFor = (name: string) => path.join(dir, name); | ||
|
|
||
| beforeEach(async () => { | ||
| dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-")); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await rm(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("streams chunks to disk in order and reports streamed on finalize", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| await registry.open("rec.webm", pathFor("rec.webm")); | ||
| await registry.append("rec.webm", Buffer.from("hello ")); | ||
| await registry.append("rec.webm", Buffer.from("world")); | ||
|
|
||
| const streamed = await registry.finalize("rec.webm"); | ||
|
|
||
| expect(streamed).toBe(true); | ||
| expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world"); | ||
| // A second finalize has nothing to close. | ||
| expect(await registry.finalize("rec.webm")).toBe(false); | ||
| }); | ||
|
|
||
| it("reports not-streamed when no stream was opened", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| expect(await registry.finalize("missing.webm")).toBe(false); | ||
| expect(registry.has("missing.webm")).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| // Parent directory does not exist, so createWriteStream emits 'error' on open. | ||
| await expect( | ||
| registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")), | ||
| ).rejects.toThrow(); | ||
| // A failed open must not register a stream the renderer would treat as live. | ||
| expect(registry.has("rec.webm")).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects append when no stream is open", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow( | ||
| /No active recording stream/, | ||
| ); | ||
| }); | ||
|
|
||
| it("discard closes the stream and removes the partial file", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| await registry.open("rec.webm", pathFor("rec.webm")); | ||
| await registry.append("rec.webm", Buffer.from("partial")); | ||
|
|
||
| await registry.discard("rec.webm", pathFor("rec.webm")); | ||
|
|
||
| expect(registry.has("rec.webm")).toBe(false); | ||
| await expect(stat(pathFor("rec.webm"))).rejects.toThrow(); | ||
| // Nothing left to finalize after a discard. | ||
| expect(await registry.finalize("rec.webm")).toBe(false); | ||
| }); | ||
|
|
||
| it("discard tolerates a missing file", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it("opening the same file twice replaces the prior stream", async () => { | ||
| const registry = new RecordingStreamRegistry(); | ||
| await registry.open("rec.webm", pathFor("rec.webm")); | ||
| await registry.append("rec.webm", Buffer.from("first")); | ||
| await registry.open("rec.webm", pathFor("rec.webm")); | ||
| await registry.append("rec.webm", Buffer.from("second")); | ||
| await registry.finalize("rec.webm"); | ||
|
|
||
| expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import { createWriteStream, type WriteStream } from "node:fs"; | ||
| import { unlink } from "node:fs/promises"; | ||
| import type { IpcMain } from "electron"; | ||
|
|
||
| /** | ||
| * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed | ||
| * by the recording's output file name. Browser MediaRecorder chunks are appended | ||
| * here as they arrive so a long recording never buffers the whole video in the | ||
| * renderer (the #616 fix). | ||
| * | ||
| * The file name is the key because it is the one value the renderer and main | ||
| * process already exchange and it is globally unique per recording, so there is | ||
| * no derived/offset key to keep in sync across the IPC boundary. | ||
| */ | ||
| export class RecordingStreamRegistry { | ||
| private readonly streams = new Map<string, WriteStream>(); | ||
|
|
||
| /** | ||
| * Open a write stream and resolve only once the OS confirms it is writable. | ||
| * Resolving on the `open` event (rather than on `createWriteStream` returning) | ||
| * means a bad path or permission error rejects here instead of surfacing as a | ||
| * silent chunk drop later, so the renderer's fallback can take over. | ||
| */ | ||
| async open(fileName: string, filePath: string): Promise<void> { | ||
| await this.endStream(fileName); | ||
|
|
||
| const ws = createWriteStream(filePath, { flags: "w" }); | ||
| await new Promise<void>((resolve, reject) => { | ||
| const onError = (error: Error) => reject(error); | ||
| ws.once("error", onError); | ||
| ws.once("open", () => { | ||
| ws.removeListener("error", onError); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| // Keep a listener for the stream's lifetime so a late error logs rather | ||
| // than crashing the main process with an unhandled 'error' event. Per-write | ||
| // failures still surface through the `append` callback below. | ||
| ws.on("error", (error) => { | ||
| console.error(`[recording-stream] ${fileName}:`, error); | ||
| }); | ||
|
|
||
| this.streams.set(fileName, ws); | ||
| } | ||
|
|
||
| has(fileName: string): boolean { | ||
| return this.streams.has(fileName); | ||
| } | ||
|
|
||
| /** Append a chunk; rejects if no stream is open or the write fails. */ | ||
| async append(fileName: string, chunk: Buffer): Promise<void> { | ||
| const ws = this.streams.get(fileName); | ||
| if (!ws) { | ||
| throw new Error(`No active recording stream for ${fileName}`); | ||
| } | ||
| await new Promise<void>((resolve, reject) => { | ||
| ws.write(chunk, (error) => (error ? reject(error) : resolve())); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Flush and close the stream, keeping the file. Returns whether a stream was | ||
| * open — i.e. whether the recording was streamed to disk (true) or needs its | ||
| * in-memory buffer written by the caller (false). | ||
| */ | ||
| async finalize(fileName: string): Promise<boolean> { | ||
| const ws = this.streams.get(fileName); | ||
| if (!ws) { | ||
| return false; | ||
| } | ||
| this.streams.delete(fileName); | ||
| await new Promise<void>((resolve, reject) => { | ||
| ws.end((error?: Error | null) => (error ? reject(error) : resolve())); | ||
| }); | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Close the stream (if any) and delete the partial file. Used when a streamed | ||
| * recording is discarded or fails before a successful save, so cancelled runs | ||
| * don't leak file descriptors or orphan partial recordings on disk. | ||
| */ | ||
| async discard(fileName: string, filePath: string): Promise<void> { | ||
| await this.endStream(fileName); | ||
| await unlink(filePath).catch(() => undefined); | ||
| } | ||
|
|
||
| private async endStream(fileName: string): Promise<void> { | ||
| const ws = this.streams.get(fileName); | ||
| if (!ws) { | ||
| return; | ||
| } | ||
| this.streams.delete(fileName); | ||
| await new Promise<void>((resolve) => ws.end(() => resolve())); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Register the streaming IPC handlers. Thin wrappers that translate the | ||
| * registry's throw-on-failure contract into the `{ success, error }` shape the | ||
| * renderer expects. | ||
| */ | ||
| export function registerRecordingStreamHandlers( | ||
| ipcMain: IpcMain, | ||
| registry: RecordingStreamRegistry, | ||
| resolveRecordingOutputPath: (fileName: string) => string, | ||
| ): void { | ||
| ipcMain.handle( | ||
| "open-recording-stream", | ||
| async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { | ||
| try { | ||
| await registry.open(fileName, resolveRecordingOutputPath(fileName)); | ||
| return { success: true }; | ||
| } catch (error) { | ||
| return { success: false, error: String(error) }; | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| ipcMain.handle( | ||
| "append-recording-chunk", | ||
| async ( | ||
| _, | ||
| fileName: string, | ||
| chunk: ArrayBuffer, | ||
| ): Promise<{ success: boolean; error?: string }> => { | ||
| try { | ||
| await registry.append(fileName, Buffer.from(chunk)); | ||
| return { success: true }; | ||
| } catch (error) { | ||
| return { success: false, error: String(error) }; | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| ipcMain.handle( | ||
| "close-recording-stream", | ||
| async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { | ||
| try { | ||
| await registry.discard(fileName, resolveRecordingOutputPath(fileName)); | ||
| return { success: true }; | ||
| } catch (error) { | ||
| return { success: false, error: String(error) }; | ||
| } | ||
| }, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discard path hides real delete failures
lowkey risky: Line 85 swallows all
unlinkerrors, soclose-recording-streamcan report success while a partial recording still exists on disk. Ignore onlyENOENT; rethrow the rest.Suggested fix
async discard(fileName: string, filePath: string): Promise<void> { await this.endStream(fileName); - await unlink(filePath).catch(() => undefined); + try { + await unlink(filePath); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + throw error; + } + } }🤖 Prompt for AI Agents