From fd2f90c7d81d4c2a57848bb21e225cbccc66ec4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 20:24:32 +0000 Subject: [PATCH] fix(cli): a background preview launch keeps only the server its own child started (#4914) * fix(cli): a background preview launch keeps only the server its own child started * fix(cli): the detached preview child always starts its own server --- .../cli/src/commands/previewLifecycle.test.ts | 59 ++++++++++++++++++- packages/cli/src/commands/previewLifecycle.ts | 21 +++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 9f1c1cd974..57b1ebfc71 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -52,6 +52,7 @@ function reachableChildDependencies(stateHome: string, { immortal = false } = {} kill: vi.fn(() => { killed = true; }), + isDescendant: (child: number, ancestor: number) => child === 9876 && ancestor === 4321, stateHome, }; } @@ -86,7 +87,21 @@ describe("background preview lifecycle", () => { "--open", "--json", ]), - ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--foreground", "--no-open"]); + ).toEqual([ + "/opt/hyperframes/cli.js", + "preview", + projectDir, + "--foreground", + "--no-open", + "--force-new", + ]); + }); + + it("makes the detached child start its own server, never reuse a sibling launch's", () => { + const args = buildBackgroundPreviewArgs(["cli.js", "preview", projectDir, "--force-new"]); + + expect(args.filter((arg) => arg === "--force-new")).toHaveLength(1); + expect(args.at(-1)).toBe("--force-new"); }); it("reuses an already-running server for the same project", async () => { @@ -427,6 +442,7 @@ describe("background preview lifecycle", () => { spawned = true; return { pid: 4321, unref: vi.fn() }; }, + isDescendant: (child, ancestor) => child === 9876 && ancestor === 4321, stateHome, }); @@ -483,6 +499,47 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); }); + it("two launches at once each keep the server their own child started", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + // A's server (3002) answers only after B's (3003) has appeared, and after A's pre-launch snapshot. + const children: Record = { + 4321: { ...server, port: 3002, pid: "9876", after: 3 }, + 5555: { ...server, port: 3003, pid: "7777", after: 0 }, + }; + const parent: Record = { 9876: 4321, 7777: 5555 }; + const spawnedAt = new Map(); + let scans = 0; + let secondSpawned!: () => void; + const secondUp = new Promise((done) => (secondSpawned = done)); + const scan = async () => { + scans++; + return [...spawnedAt] + .filter(([wrapper, at]) => scans - at > children[wrapper]!.after) + .map(([wrapper]) => children[wrapper]!); + }; + const launch = (wrapper: number, sleep: () => Promise, preferredPort?: number) => + startBackgroundPreview(projectDir, 3002, { + scan, + spawn: () => { + spawnedAt.set(wrapper, scans); + if (wrapper === 5555) secondSpawned(); + return { pid: wrapper, unref: vi.fn() }; + }, + sleep, + kill: vi.fn(), + isDescendant: (child: number, ancestor: number) => parent[child] === ancestor, + stateHome, + preferredPort, + }); + + const first = launch(4321, () => secondUp, 3002); + await vi.waitFor(() => expect(spawnedAt.has(4321)).toBe(true)); + const second = launch(5555, async () => {}); + + await expect(first).resolves.toMatchObject({ type: "started", port: 3002, pid: 9876 }); + await expect(second).resolves.toMatchObject({ type: "started", port: 3003, pid: 7777 }); + }); + it("keeps the next free port when no explicit port was requested", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); const dependencies = reachableChildDependencies(stateHome); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 354141db92..9e88cb81a9 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -258,10 +258,16 @@ function startedServer( servers: ActiveServer[], projectDir: string, preLaunchPorts: Set, - browserGpuMode?: BrowserGpuMode, + launchedPid: number, + dependencies: LifecycleDependencies, ): ActiveServer | null { - const candidates = servers.filter((server) => !preLaunchPorts.has(server.port)); - return matchingServer(candidates, projectDir, browserGpuMode); + const isDescendant = dependencies.isDescendant ?? isProcessDescendant; + const ours = (server: ActiveServer) => { + const pid = Number(server.pid); + return pid === launchedPid || isDescendant(pid, launchedPid); + }; + const candidates = servers.filter((server) => !preLaunchPorts.has(server.port) && ours(server)); + return matchingServer(candidates, projectDir, dependencies.browserGpuMode); } export function buildBackgroundPreviewArgs(argv: string[]): string[] { @@ -273,9 +279,10 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] { !arg.startsWith("--foreground=") && arg !== "--open" && arg !== "--no-open" && + arg !== "--force-new" && arg !== "--json", ); - return [...filtered, "--foreground", "--no-open"]; + return [...filtered, "--foreground", "--no-open", "--force-new"]; } export async function readBackgroundPreviewStatus( @@ -547,7 +554,7 @@ export async function startBackgroundPreview( ); const kill = dependencies.kill ?? stopProcess; - const server = await awaitStartedServer(projectDir, startPort, preLaunchPorts, dependencies); + const server = await awaitStartedServer(projectDir, startPort, preLaunchPorts, pid, dependencies); if (!server) { await kill(pid); throw new Error(`background preview did not become ready; see ${logPath}`); @@ -584,6 +591,7 @@ async function awaitStartedServer( projectDir: string, startPort: number, preLaunchPorts: Set, + launchedPid: number, dependencies: LifecycleDependencies, ): Promise { const scan = dependencies.scan ?? scanActiveServers; @@ -593,7 +601,8 @@ async function awaitStartedServer( await scan(startPort), projectDir, preLaunchPorts, - dependencies.browserGpuMode, + launchedPid, + dependencies, ); if (server) return server; await sleep(200);