diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts index d450a8a87..03f3c55ab 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -295,4 +295,215 @@ describe("LinuxNativeCaptureSession", () => { expect(session.grantedSourceKind).toBe("window"); }); + + /** + * The latch `waitUntilSourceSelected` has and this one did not. + * + * `start-native-linux-recording` arms the helper and only THEN awaits this, + * so `capture-started` can already have been read — the helper emits it as + * soon as the first frame stages, and a stdout chunk carrying it is parsed + * synchronously. Without a latch the answer is dropped on the floor and the + * returned promise has nothing left to resolve it: the recording is running, + * the file is filling, and the app waits for it forever. + */ + it("resolves the capture wait immediately once the first frame already landed", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await flushStdout(); + + // Callers must not have to race the event to observe it. + await expect(session.waitUntilCapturing()).resolves.toBeUndefined(); + }); + + /** + * The same loss through the ONE path that makes it more than theoretical: + * both events in a single stdout chunk. `NdjsonLineReader` hands them to the + * session back to back within one `data` callback, so no caller — however + * promptly it awaits — can install its handler in between. + */ + it("resolves the capture wait when arming and the first frame share a stdout chunk", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + helper.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + event: "source-selected", + timestampMs: 1_100, + nodeId: 42, + sourceKind: "window", + })}\n${JSON.stringify({ + schemaVersion: 1, + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + })}\n`, + ); + await flushStdout(); + + await expect(session.waitUntilCapturing()).resolves.toBeUndefined(); + }); + + /** + * The latch must not swallow a failure that arrived first: a helper that died + * before any frame has nothing to report, and the caller has to learn that + * rather than be told the capture is running. + */ + it("still rejects the capture wait when the helper died before any frame", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + helper.emit("exit", 1, null); + await flushStdout(); + + await expect(session.waitUntilCapturing()).rejects.toThrow(); + }); + /** + * The latch is a shortcut past the WAIT, never past the liveness check. A + * helper that died after its first frame has stopped recording, so a caller + * arriving late must still be told — answering "capturing" from a latch set + * before the crash would report a recording that is no longer running. + */ + it("rejects the capture wait when the helper died after its first frame", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await flushStdout(); + helper.emit("exit", 1, null); + await flushStdout(); + + await expect(session.waitUntilCapturing()).rejects.toThrow(); + }); + + /** + * The same early answer on a session that was never deferred. Nothing about + * the loss is specific to `deferStart`: the helper connects immediately, so + * the first frame can land even sooner relative to the caller's `await`. + */ + it("resolves the capture wait on an undeferred session whose first frame already landed", async () => { + const session = newSession(); + await startReady(session); + + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await flushStdout(); + + await expect(session.waitUntilCapturing()).resolves.toBeUndefined(); + }); + + /** + * Asking twice must answer twice. The handler clears `startedResolve` after + * it fires, so a second caller has no event left to wake it — only the latch + * can, and a running capture does not stop being running because someone + * already asked about it. + */ + it("resolves a second capture wait after the first was answered by the event", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + const first = session.waitUntilCapturing(); + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await flushStdout(); + await first; + + await expect(session.waitUntilCapturing()).resolves.toBeUndefined(); + }); + + /** + * A diagnostic that arrives after the first frame does not end the capture: + * `error` records the cause and fails the picker wait, but the helper is + * still alive and still encoding. The latch must answer for it. + */ + it("resolves the capture wait after a non-fatal error follows the first frame", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + helper.emitEvent({ + event: "error", + timestampMs: 1_300, + code: "audio-unavailable", + message: "the system audio node vanished", + }); + await flushStdout(); + + await expect(session.waitUntilCapturing()).resolves.toBeUndefined(); + }); + + /** + * (control) The latch starts closed, so it must not answer for a capture + * that has not started — the wait is still a wait. Passes on both arms by + * construction; it controls against a latch initialised `true` or read + * unconditionally, either of which would make the discriminating tests above + * pass for the wrong reason. + */ + it("(control) leaves the capture wait pending until the first frame lands", async () => { + const session = newSession(true); + await startReady(session); + + session.arm(); + const pending = session.waitUntilCapturing(); + const settled = Promise.race([ + pending.then(() => "settled"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(await settled).toBe("pending"); + + helper.emitEvent({ + event: "capture-started", + timestampMs: 1_200, + path: "/tmp/recording.mp4", + width: 800, + height: 600, + fps: 30, + }); + await flushStdout(); + await expect(pending).resolves.toBeUndefined(); + }); }); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index f1582ee59..689097392 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -76,6 +76,7 @@ export class LinuxNativeCaptureSession { private readyReject: ((error: Error) => void) | null = null; private readyTimer: NodeJS.Timeout | null = null; + private capturing = false; private startedResolve: (() => void) | null = null; private startedReject: ((error: Error) => void) | null = null; @@ -229,6 +230,13 @@ export class LinuxNativeCaptureSession { if (!this.process) { return Promise.reject(new Error("The Linux capture helper is not running.")); } + // Checked AFTER the liveness test, unlike the latch in + // [`waitUntilSourceSelected`]: a helper that died after its first frame + // has stopped recording, so reporting a running capture would be worse + // than the wait this latch exists to end. + if (this.capturing) { + return Promise.resolve(); + } return new Promise((resolve, reject) => { this.startedResolve = resolve; this.startedReject = reject; @@ -421,6 +429,7 @@ export class LinuxNativeCaptureSession { // before the portal picker. The recording's zero is HERE, so the // telemetry is re-based onto it and anything from during the // picker is dropped rather than left pinned to the start. + this.capturing = true; this.cursor.rebase(payload.timestampMs); console.info( "[capture-linux] capture started",