Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions electron/native-bridge/capture/linuxNativeCaptureSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
9 changes: 9 additions & 0 deletions electron/native-bridge/capture/linuxNativeCaptureSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<void>((resolve, reject) => {
this.startedResolve = resolve;
this.startedReject = reject;
Expand Down Expand Up @@ -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",
Expand Down
Loading