Skip to content

Commit 968f82a

Browse files
committed
fix(capture): enumerate screens and windows in separate calls on X11
Asking desktopCapturer.getSources for both types in one call intermittently fails the X11 screen capturer's thumbnail grab: Electron 41 never settles the call, Electron 43 settles after 3s without the screen. Measured under Xvfb: 7 failures in 16 combined runs, none in 32 runs with one type per call. Wayland keeps the single call, where splitting would raise the portal twice. Refs #462.
1 parent d0091da commit 968f82a

4 files changed

Lines changed: 122 additions & 17 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { getSourcesByType, shouldEnumerateSourceTypesSeparately } from "./desktopSourceTypes";
3+
4+
describe("shouldEnumerateSourceTypesSeparately", () => {
5+
it("splits on Linux X11, including a bare Xvfb with no session variables", () => {
6+
expect(shouldEnumerateSourceTypesSeparately("linux", {})).toBe(true);
7+
expect(shouldEnumerateSourceTypesSeparately("linux", { XDG_SESSION_TYPE: "x11" })).toBe(true);
8+
});
9+
10+
it("keeps one call on Wayland, where splitting would raise the portal twice", () => {
11+
expect(shouldEnumerateSourceTypesSeparately("linux", { XDG_SESSION_TYPE: "wayland" })).toBe(
12+
false,
13+
);
14+
expect(shouldEnumerateSourceTypesSeparately("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(
15+
false,
16+
);
17+
});
18+
19+
it("keeps one call on Windows and macOS", () => {
20+
expect(shouldEnumerateSourceTypesSeparately("win32", {})).toBe(false);
21+
expect(shouldEnumerateSourceTypesSeparately("darwin", {})).toBe(false);
22+
});
23+
});
24+
25+
describe("getSourcesByType", () => {
26+
const opts: Electron.SourcesOptions = {
27+
types: ["screen", "window"],
28+
thumbnailSize: { width: 32, height: 18 },
29+
};
30+
31+
it("asks one type per call, one call at a time, and concatenates in order", async () => {
32+
let inFlight = 0;
33+
let maxInFlight = 0;
34+
const getSources = vi.fn(async (o: Electron.SourcesOptions) => {
35+
inFlight += 1;
36+
maxInFlight = Math.max(maxInFlight, inFlight);
37+
await new Promise((resolve) => setTimeout(resolve, 1));
38+
inFlight -= 1;
39+
return o.types.map((type) => `${type}:0`);
40+
});
41+
42+
await expect(getSourcesByType(opts, getSources, true)).resolves.toEqual([
43+
"screen:0",
44+
"window:0",
45+
]);
46+
expect(getSources.mock.calls.map(([o]) => o)).toEqual([
47+
{ ...opts, types: ["screen"] },
48+
{ ...opts, types: ["window"] },
49+
]);
50+
expect(maxInFlight).toBe(1);
51+
});
52+
53+
it("passes the request through untouched when not splitting", async () => {
54+
const getSources = vi.fn(async () => ["screen:0", "window:0"]);
55+
await getSourcesByType(opts, getSources, false);
56+
expect(getSources).toHaveBeenCalledExactlyOnceWith(opts);
57+
});
58+
59+
it("passes a single-type request through as one call", async () => {
60+
const getSources = vi.fn(async () => ["screen:0"]);
61+
const screensOnly: Electron.SourcesOptions = { ...opts, types: ["screen"] };
62+
await getSourcesByType(screensOnly, getSources, true);
63+
expect(getSources).toHaveBeenCalledExactlyOnceWith(screensOnly);
64+
});
65+
66+
it("propagates a failure of either call", async () => {
67+
const getSources = vi.fn(async (o: Electron.SourcesOptions) => {
68+
if (o.types[0] === "window") throw new Error("boom");
69+
return ["screen:0"];
70+
});
71+
await expect(getSourcesByType(opts, getSources, true)).rejects.toThrow("boom");
72+
});
73+
});

‎electron/ipc/desktopSourceTypes.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Issue #462. On X11, one desktopCapturer.getSources call asking for both
2+
// "screen" and "window" runs Chromium's two X11 capturers together, and the
3+
// screen capturer's thumbnail grab then intermittently fails ("Failed to
4+
// initialize pixel buffer"). What that costs depends on the Electron version:
5+
// 41 never settles the call at all, 43 settles after its 3s ready timer with the
6+
// screen silently missing. Measured under Xvfb, 8 runs per cell: the combined
7+
// call failed 3/8 (Electron 43) and 4/8 (Electron 41); asked one type per call,
8+
// 32 runs across both versions and both orders never lost the screen.
9+
//
10+
// So on X11 each type gets its own call, one after the other. Not on Wayland:
11+
// there Electron serves both types from a single PipeWire capturer, and one
12+
// call per type would raise the portal picker twice. Not on Windows or macOS
13+
// either, where nothing points at this and the combined call is what ships.
14+
export function shouldEnumerateSourceTypesSeparately(
15+
platform: NodeJS.Platform,
16+
env: Partial<NodeJS.ProcessEnv>,
17+
): boolean {
18+
// The same test main.ts uses to put Chromium on its Wayland backend.
19+
const isWayland = env.XDG_SESSION_TYPE === "wayland" || env.WAYLAND_DISPLAY !== undefined;
20+
return platform === "linux" && !isWayland;
21+
}
22+
23+
export async function getSourcesByType<T>(
24+
opts: Electron.SourcesOptions,
25+
getSources: (opts: Electron.SourcesOptions) => Promise<T[]>,
26+
separately: boolean,
27+
): Promise<T[]> {
28+
if (!separately || opts.types.length < 2) return getSources(opts);
29+
const sources: T[] = [];
30+
// Sequential on purpose: running them in parallel is the failure.
31+
for (const type of opts.types) {
32+
sources.push(...(await getSources({ ...opts, types: [type] })));
33+
}
34+
return sources;
35+
}

‎electron/ipc/handlers.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import {
140140
shouldEnumerateRecordingSources,
141141
shouldPersistSelectedSource,
142142
} from "../recording-source-settings";
143+
import { getSourcesByType, shouldEnumerateSourceTypesSeparately } from "./desktopSourceTypes";
143144
import { registerNativeBridgeHandlers } from "./nativeBridge";
144145
import { createNativeMacMidCaptureErrorWatch } from "./nativeMacMidCaptureErrorWatch";
145146
import { registerRecordingPrefsHandlers } from "./recordingPrefs";
@@ -1982,18 +1983,19 @@ export function registerIpcHandlers(
19821983
// await it, and a renderer-side race would only stop *waiting* while this
19831984
// keeps running and its reply goes to nobody. Rejecting is what turns an
19841985
// indefinite spinner into the pickers' existing error branch.
1985-
// How long it actually took, under the existing diagnostic flag. The bound
1986-
// above turned an indefinite hang into a named failure, which is where the
1987-
// open question starts rather than ends: on a headless runner `openscreen
1988-
// sources` gets an answer within 20s four times in five while `record` --
1989-
// the same call with the same options -- exceeds 30s every time. A duration
1990-
// on both paths is what tells those apart; a threshold alone cannot.
1986+
// How long it actually took, under the existing diagnostic flag. That
1987+
// duration is what showed the headless hang (#462) to be bimodal -- ~17ms
1988+
// or never -- and so not a threshold problem; see desktopSourceTypes.ts.
19911989
const startedAt = Date.now();
19921990
const diagnostic = isDiagnosticModeEnabled();
19931991
let sources: Awaited<ReturnType<typeof desktopCapturer.getSources>>;
19941992
try {
19951993
sources = await withDeadline(
1996-
desktopCapturer.getSources(opts),
1994+
getSourcesByType(
1995+
opts,
1996+
(typeOpts) => desktopCapturer.getSources(typeOpts),
1997+
shouldEnumerateSourceTypesSeparately(process.platform, process.env),
1998+
),
19971999
GET_SOURCES_TIMEOUT_MS,
19982000
`Desktop source enumeration did not return within ${GET_SOURCES_TIMEOUT_MS}ms. ` +
19992001
"This usually means the display or GPU stack cannot be reached — check that a display server is available.",

‎src/cli/CliSourcesRunner.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,11 @@ async function enumerateMicrophones(): Promise<{
110110
}
111111

112112
async function enumerateSources(): Promise<CliSourcesResult> {
113-
// desktopCapturer.getSources is unbounded on the main side, and on a host
114-
// whose GL stack is broken -- a CI runner, a container, a server where ANGLE
115-
// cannot initialise -- it can simply never return. `openscreen sources` hung
116-
// forever on four of five headless attempts, always here: the milestones show
117-
// the renderer asking for its request and then going silent, which is the call
118-
// immediately after.
119-
//
120-
// Bounded on this side rather than in the shared get-sources handler, which
121-
// the GUI uses too and where a timeout would change behaviour nobody asked to
122-
// change. Failing in twenty seconds with a reason beats hanging until killed.
113+
// desktopCapturer.getSources can simply never return: on X11, Electron 41 did
114+
// so whenever screens and windows were enumerated in one call (#462; the
115+
// get-sources handler now splits them, see desktopSourceTypes.ts). Bounded
116+
// here as well, below the handler's own 30s, so this more specific message
117+
// wins. Failing in twenty seconds with a reason beats hanging until killed.
123118
const sources = await withTimeout<Awaited<
124119
ReturnType<typeof window.electronAPI.getSources>
125120
> | null>(

0 commit comments

Comments
 (0)