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
This commit is contained in:
Miguel Ángel
2026-10-02 20:24:32 +00:00
committed by GitHub
parent d5b56426c2
commit fd2f90c7d8
2 changed files with 73 additions and 7 deletions
@@ -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<number, ActiveServer & { after: number }> = {
4321: { ...server, port: 3002, pid: "9876", after: 3 },
5555: { ...server, port: 3003, pid: "7777", after: 0 },
};
const parent: Record<number, number> = { 9876: 4321, 7777: 5555 };
const spawnedAt = new Map<number, number>();
let scans = 0;
let secondSpawned!: () => void;
const secondUp = new Promise<void>((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<void>, 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);
+15 -6
View File
@@ -258,10 +258,16 @@ function startedServer(
servers: ActiveServer[],
projectDir: string,
preLaunchPorts: Set<number>,
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<number>,
launchedPid: number,
dependencies: LifecycleDependencies,
): Promise<ActiveServer | null> {
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);