Skip to content

Commit c16964a

Browse files
committed
fix(cli): own SIGTERM before the GPU probe browser launches
1 parent d6acb6d commit c16964a

8 files changed

Lines changed: 66 additions & 39 deletions

File tree

‎packages/cli/src/browser/launch.fixture.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { writeFileSync } from "node:fs";
33
import { registerRootExitRequester } from "../utils/commandResult.js";
44
import { ensureBrowser } from "./manager.js";
5-
import { launchManagedBrowser } from "./launch.js";
5+
import { launchManagedBrowser, resolveManagedGpuMode } from "./launch.js";
66

77
const [profileDir, readyPath] = process.argv.slice(2);
88
if (!profileDir || !readyPath) throw new Error("Missing fixture arguments");
@@ -14,6 +14,10 @@ registerRootExitRequester((exitCode) => {
1414
});
1515
const { executablePath } = await ensureBrowser();
1616
const puppeteer = await import("puppeteer-core");
17+
if (process.argv[4] === "probe") {
18+
writeFileSync(readyPath, "probing");
19+
await resolveManagedGpuMode("auto", executablePath);
20+
}
1721
writeFileSync(readyPath, "launching");
1822
const browser = await launchManagedBrowser(puppeteer.default, {
1923
headless: true,

‎packages/cli/src/browser/launch.process.test.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ function browserPids(profileDir: string): number[] {
2323
.map(Number);
2424
}
2525

26-
function startFixture(profileDir: string, readyPath: string): ChildProcess {
27-
const child = spawn(process.execPath, ["--import", "tsx", fixturePath, profileDir, readyPath], {
28-
stdio: "ignore",
29-
});
26+
function startFixture(profileDir: string, readyPath: string, mode = "launch"): ChildProcess {
27+
const child = spawn(
28+
process.execPath,
29+
["--import", "tsx", fixturePath, profileDir, readyPath, mode],
30+
{ stdio: "ignore" },
31+
);
3032
children.add(child);
3133
return child;
3234
}
@@ -67,4 +69,18 @@ describe.skipIf(process.platform !== "linux")("managed browser shutdown", () =>
6769
},
6870
60_000,
6971
);
72+
73+
it("SIGTERM during the GPU probe asks the CLI to exit instead of being swallowed", async () => {
74+
const dir = mkdtempSync(join(tmpdir(), "hf-launch-"));
75+
const readyPath = join(dir, "ready");
76+
const child = startFixture(join(dir, "profile"), readyPath, "probe");
77+
const done = exited(child);
78+
await waitForTestCondition(
79+
() => existsSync(readyPath) && readFileSync(readyPath, "utf8") === "probing",
80+
30_000,
81+
);
82+
child.kill("SIGTERM");
83+
await done;
84+
expect(readFileSync(`${readyPath}.exit`, "utf8")).toBe("143");
85+
}, 60_000);
7086
});

‎packages/cli/src/browser/launch.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import type { Browser, LaunchOptions, PuppeteerNode } from "puppeteer-core";
2+
import {
3+
resolveCaptureBrowserGpuMode,
4+
type BrowserGpuMode,
5+
type ResolvedBrowserGpuMode,
6+
} from "./gpuPolicy.js";
7+
import { setHostHandlesSigint } from "@hyperframes/engine";
28
import { requestCliExit } from "../utils/commandResult.js";
39

410
const browsers = new Set<Browser>();
@@ -20,17 +26,30 @@ function stopForSignal(signal: "SIGINT" | "SIGTERM"): void {
2026
);
2127
}
2228

29+
function ownSignals(): void {
30+
if (listening) return;
31+
listening = true;
32+
setHostHandlesSigint(true);
33+
process.on("SIGINT", () => stopForSignal("SIGINT"));
34+
process.on("SIGTERM", () => stopForSignal("SIGTERM"));
35+
}
36+
37+
/** The GPU probe launches its own Chrome, so the signal owner must exist before it runs. */
38+
export async function resolveManagedGpuMode(
39+
requestedMode: BrowserGpuMode,
40+
chromePath?: string,
41+
): Promise<ResolvedBrowserGpuMode> {
42+
ownSignals();
43+
return resolveCaptureBrowserGpuMode(requestedMode, chromePath);
44+
}
45+
2346
/** One owner for every CLI browser, including a launch cancelled before its connection is ready. */
2447
export async function launchManagedBrowser(
2548
puppeteer: PuppeteerNode,
2649
options: LaunchOptions,
2750
): Promise<Browser> {
2851
if (shutdown) throw new Error("The CLI is stopping; no browser can start.");
29-
if (!listening) {
30-
listening = true;
31-
process.on("SIGINT", () => stopForSignal("SIGINT"));
32-
process.on("SIGTERM", () => stopForSignal("SIGTERM"));
33-
}
52+
ownSignals();
3453
const abort = new AbortController();
3554
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
3655
const launch = puppeteer.launch({

‎packages/cli/src/capture/captureCompositionFrame.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { launchManagedBrowser } from "../browser/launch.js";
1+
import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js";
22
import { spawn } from "node:child_process";
33
import type { Browser, Page } from "puppeteer-core";
44
import { c } from "../ui/colors.js";
55
import {
66
assertWebGpuAdapterAvailable,
77
compositionRequiresWebGpu,
8-
resolveCaptureBrowserGpuMode,
98
resolveLocalBrowserGpuMode,
109
type BrowserGpuMode,
1110
} from "../browser/gpuPolicy.js";
@@ -180,7 +179,7 @@ export async function openSettledCompositionPage(
180179
const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode();
181180
const requiresWebGpu = compositionRequiresWebGpu(html);
182181
const launch = async (executablePath: string): Promise<Browser> => {
183-
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(requestedGpuMode, executablePath);
182+
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, executablePath);
184183
return launchManagedBrowser(puppeteer.default, {
185184
headless: true,
186185
executablePath,

‎packages/cli/src/commands/layout.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { launchManagedBrowser } from "../browser/launch.js";
1+
import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js";
22
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
33
import { defineCommand } from "citty";
44
import { existsSync, readFileSync } from "node:fs";
@@ -204,12 +204,8 @@ async function runLayoutAudit(
204204
const { ensureBrowser } = await import("../browser/manager.js");
205205
const puppeteer = await import("puppeteer-core");
206206
const { buildChromeArgs } = await import("@hyperframes/engine");
207-
const {
208-
assertWebGpuAdapterAvailable,
209-
compositionRequiresWebGpu,
210-
resolveCaptureBrowserGpuMode,
211-
resolveLocalBrowserGpuMode,
212-
} = await import("../browser/gpuPolicy.js");
207+
const { assertWebGpuAdapterAvailable, compositionRequiresWebGpu, resolveLocalBrowserGpuMode } =
208+
await import("../browser/gpuPolicy.js");
213209
const html = await bundleProjectHtml(projectDir);
214210
const server = await serveStaticProjectHtml(
215211
projectDir,
@@ -221,10 +217,7 @@ async function runLayoutAudit(
221217
try {
222218
const browser = await ensureBrowser();
223219
const requestedGpuMode = resolveLocalBrowserGpuMode();
224-
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(
225-
requestedGpuMode,
226-
browser.executablePath,
227-
);
220+
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, browser.executablePath);
228221
const requiresWebGpu = compositionRequiresWebGpu(html);
229222
chromeBrowser = await launchManagedBrowser(puppeteer.default, {
230223
headless: true,

‎packages/cli/src/commands/motionShot.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { launchManagedBrowser } from "../browser/launch.js";
1+
import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js";
22
// Onion-skin motion screenshot: seek the LIVE timeline at N equal-time steps and
33
// project the REAL element at each step, so an agent can SELF-VERIFY motion (the
44
// rendered result — every channel: position, rotation, scale, opacity, colour),
@@ -18,7 +18,6 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport
1818
import {
1919
assertWebGpuAdapterAvailable,
2020
compositionRequiresWebGpu,
21-
resolveCaptureBrowserGpuMode,
2221
resolveLocalBrowserGpuMode,
2322
} from "../browser/gpuPolicy.js";
2423
import {
@@ -401,7 +400,7 @@ async function openCompositionPage(
401400
const { buildChromeArgs } = await import("@hyperframes/engine");
402401
const size = resolveCompositionViewportFromHtml(html);
403402
const requestedGpuMode = resolveLocalBrowserGpuMode();
404-
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(requestedGpuMode, executablePath);
403+
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, executablePath);
405404
const requiresWebGpu = compositionRequiresWebGpu(html);
406405
const browser = await launchManagedBrowser(puppeteer.default, {
407406
headless: true,

‎packages/cli/src/commands/validate.ts‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { launchManagedBrowser } from "../browser/launch.js";
1+
import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js";
22
// The media-metadata wait exists twice on purpose: once Node-side and once
33
// inside a page.evaluate() body, which is serialized into the browser and
44
// cannot import the Node helper. Line-level markers don't survive the clone
@@ -433,15 +433,9 @@ async function validateInBrowser(
433433
const puppeteer = await import("puppeteer-core");
434434
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
435435
const requestedGpuMode = resolveCliChromeGpuMode();
436-
const {
437-
assertWebGpuAdapterAvailable,
438-
compositionRequiresWebGpu,
439-
resolveCaptureBrowserGpuMode,
440-
} = await import("../browser/gpuPolicy.js");
441-
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(
442-
requestedGpuMode,
443-
browser.executablePath,
444-
);
436+
const { assertWebGpuAdapterAvailable, compositionRequiresWebGpu } =
437+
await import("../browser/gpuPolicy.js");
438+
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, browser.executablePath);
445439
const requiresWebGpu = compositionRequiresWebGpu(html);
446440
const chromeBrowser = await launchManagedBrowser(puppeteer.default, {
447441
headless: true,

‎packages/engine/src/services/browserManager.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ let _puppeteer: PuppeteerNode | undefined;
3333

3434
let hostHandlesSigint = false;
3535

36-
/** Set while the host cancels renders on Ctrl+C; Puppeteer's own handler would exit before any cleanup ran. */
36+
/** Set while the host owns SIGINT and SIGTERM; Puppeteer's own handlers would exit before any cleanup ran. */
3737
export function setHostHandlesSigint(owned: boolean): void {
3838
hostHandlesSigint = owned;
3939
}
4040

41-
export const sigintLaunchOptions = () => ({ handleSIGINT: !hostHandlesSigint });
41+
export const sigintLaunchOptions = () => ({
42+
handleSIGINT: !hostHandlesSigint,
43+
handleSIGTERM: !hostHandlesSigint,
44+
});
4245

4346
interface WebGlProbeInfo {
4447
hasWebGL: boolean;

0 commit comments

Comments
 (0)