mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): honor an explicit --port for background preview reuse and launch (#3915)
* fix: honor explicit --port when reusing a background preview server startBackgroundPreview never compared a reuse candidate's actual bound port against the caller's explicitly requested --port before returning it, so a second `preview --port <N>` call could silently return an existing server on a different port instead of honoring the request. Reuses the existing PreviewServerPortMismatchError (already used by findPreviewServerForProject for the same discipline) instead of adding a new error type: an explicit --port that doesn't match the reuse candidate now throws a clear conflict rather than substituting the wrong port silently. A bare launch with no --port keeps reusing any project-matching server, unchanged. Deliberately out of scope: the interactive/embedded launch path (runEmbeddedMode -> findPortAndServe) has the same silent-substitution shape when a same-project server is found mid-scan at a port other than the one requested. That path's output isn't the JSON lifecycle schema this bug was reported against, and fixing it would mean touching the interactive dev-server bind/scan loop rather than a simple pre-return check, so it's left as a follow-up candidate. Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com> * fix: honor explicit --port on every background preview exit path `startBackgroundPreview` already refused to reuse a same-project server on the wrong port when --port was explicit, but the fresh-launch path still trusted whatever port the detached child bound. The child scans upward from --port and takes the first free port, so `--background --port N` with N busy reported `{ type: "started", port: N+1 }` and recorded ownership of it. - After the child comes up, compare its port with the explicit --port. On a mismatch, reap the wrapper, wait until the substitute stops answering, and throw `PreviewPortUnavailableError` (JSON code `preview-port-unavailable`). If the substitute never stops, fail loudly instead of reporting it. - Share one `unmetPreferredPort` predicate between the reuse and launch paths, and one `awaitServerGone` wait between the reap path and `stopBackgroundPreview`. - When several same-project servers are running, prefer the one on the explicit --port as the reuse candidate instead of the lowest port. - Map launch failures to JSON codes in one place (`backgroundStartFailureCode`). Tests: preferred port bound → reported as-is; preferred port taken → reaped and rejected with both ports, no session record; substitute that keeps serving → distinct error; bare launch keeps the next free port; explicit port picks the matching sibling. Deleting the launch-path check, the candidate ordering, or the post-kill wait each turns its test red. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(cli): reuse the same-project server on the explicit --port `startBackgroundPreview` picked its reuse candidate before honouring `--port`, so with an owned server on 3002 and an unmanaged same-project server on 3003, `--port 3003` rejected with "No Studio preview server … on port 3003. Matching server port: 3002" even though 3003 was running. - A policy-matching same-project server already on the explicit port is now the reuse candidate, owned or not; the owned server stays authoritative otherwise, and an explicit GPU-policy change on the requested port still replaces it. - `PreviewServerPortMismatchError` receives every policy-matching same-project server, so the ports it reports are the ones running. - `policyMatchingServers` is the single owner of the same-project + GPU-policy predicate; `matchingServer` derives from it. - `BackgroundPreviewResult` names the launch result union once; `PREVIEW_PORT_MISMATCH_CODE` replaces three literal error codes. Tests: unmanaged sibling on the explicit port is reused without touching the owned server; mismatch error lists every same-project port and no foreign-project port; owned server on the explicit port is replaced on a GPU-policy change. Each new check fails when its guard is removed. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * refactor(cli): keep the --port honour comments short * fix(cli): drop the scan binding stopBackgroundPreview no longer reads * refactor(cli): keep comment share at base in the preview lifecycle files * fix(cli): reuse a same-project server on an explicit --port beyond the scan window * test(cli): pin the JSON codes for a background start that misses --port * refactor(cli): probe an out-of-window --port in its own helper --------- Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
fc53908742
commit
fcc637068c
@@ -5,8 +5,11 @@ import { fileURLToPath } from "node:url";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommand } from "citty";
|
||||
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
|
||||
import { PreviewPortUnavailableError } from "./previewLifecycle.js";
|
||||
import {
|
||||
default as previewCommand,
|
||||
backgroundStartFailureCode,
|
||||
foregroundPreviewReadyPayload,
|
||||
prebuildPreview,
|
||||
handlePreviewKillAll,
|
||||
@@ -369,6 +372,14 @@ describe("preview lifecycle JSON failures", () => {
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new PreviewServerPortMismatchError(3500, []), "preview-port-mismatch"],
|
||||
[new PreviewPortUnavailableError(3500, 3501), "preview-port-unavailable"],
|
||||
[new Error("spawn failed"), "preview-start-failed"],
|
||||
])("maps a background start failure to its JSON code (%#)", (error, code) => {
|
||||
expect(backgroundStartFailureCode(error)).toBe(code);
|
||||
});
|
||||
|
||||
it("wraps missing-project start failures without human stderr", async () => {
|
||||
const missing = join(tmpdir(), `hf-preview-missing-start-${process.pid}-${Date.now()}`);
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
@@ -62,7 +62,9 @@ import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.j
|
||||
import { resolveProject, resolveProjectOrThrow } from "../utils/project.js";
|
||||
import { resolveAutoProxy } from "../utils/projectConfig.js";
|
||||
import { studioProxyEnv } from "../utils/studioProxyEnv.js";
|
||||
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
|
||||
import {
|
||||
PreviewPortUnavailableError,
|
||||
listBackgroundPreviewStatuses,
|
||||
readBackgroundPreviewStatus,
|
||||
startBackgroundPreview,
|
||||
@@ -124,6 +126,14 @@ type CompactSelectionPayload = Pick<
|
||||
|
||||
const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];
|
||||
|
||||
const PREVIEW_PORT_MISMATCH_CODE = "preview-port-mismatch";
|
||||
|
||||
export function backgroundStartFailureCode(error: unknown): string {
|
||||
if (error instanceof PreviewServerPortMismatchError) return PREVIEW_PORT_MISMATCH_CODE;
|
||||
if (error instanceof PreviewPortUnavailableError) return "preview-port-unavailable";
|
||||
return "preview-start-failed";
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "preview",
|
||||
@@ -299,7 +309,7 @@ export default defineCommand({
|
||||
if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware";
|
||||
if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software";
|
||||
const startPort = parseInt(args.port ?? "3002", 10);
|
||||
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
||||
const explicitPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
||||
|
||||
if (args.status || args.stop) {
|
||||
try {
|
||||
@@ -388,18 +398,13 @@ export default defineCommand({
|
||||
json: Boolean(args.json),
|
||||
fields: args["context-fields"] as string | undefined,
|
||||
detail: args["context-detail"] as string | undefined,
|
||||
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
|
||||
preferredPort: explicitPort,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.selection) {
|
||||
const project = resolveProject(args.dir);
|
||||
return printCurrentSelection(
|
||||
project.dir,
|
||||
startPort,
|
||||
Boolean(args.json),
|
||||
preferredContextPort,
|
||||
);
|
||||
return printCurrentSelection(project.dir, startPort, Boolean(args.json), explicitPort);
|
||||
}
|
||||
|
||||
const rawArg = args.dir;
|
||||
@@ -502,11 +507,14 @@ export default defineCommand({
|
||||
// the existing managed server resolved earlier. Only an explicit
|
||||
// --browser-gpu/--no-browser-gpu request authorizes replacement.
|
||||
browserGpuMode: args["browser-gpu"] === undefined ? undefined : browserGpuMode,
|
||||
preferredPort: explicitPort,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
if (args.json) {
|
||||
writeLifecycleJson(lifecycleFailurePayload("start", "preview-start-failed", message));
|
||||
writeLifecycleJson(
|
||||
lifecycleFailurePayload("start", backgroundStartFailureCode(error), message),
|
||||
);
|
||||
} else {
|
||||
clack.log.error(message);
|
||||
}
|
||||
@@ -923,7 +931,7 @@ async function printCurrentSelection(
|
||||
return;
|
||||
}
|
||||
if (err instanceof PreviewServerPortMismatchError) {
|
||||
printSelectionFailure("preview-port-mismatch", err.message, json);
|
||||
printSelectionFailure(PREVIEW_PORT_MISMATCH_CODE, err.message, json);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
@@ -1045,7 +1053,7 @@ async function printCurrentContext(
|
||||
return;
|
||||
}
|
||||
if (err instanceof PreviewServerPortMismatchError) {
|
||||
printSelectionFailure("preview-port-mismatch", err.message, options.json);
|
||||
printSelectionFailure(PREVIEW_PORT_MISMATCH_CODE, err.message, options.json);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import type { ActiveServer } from "../server/portUtils.js";
|
||||
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
|
||||
import {
|
||||
PreviewPortUnavailableError,
|
||||
buildBackgroundPreviewArgs,
|
||||
listBackgroundPreviewStatuses,
|
||||
previewSessionPath,
|
||||
@@ -35,6 +37,25 @@ function savePreviewSession(stateHome: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Launch deps whose wrapper (PID 4321) brings up `server` (PID 9876) on spawn and takes it down on kill. */
|
||||
function reachableChildDependencies(stateHome: string, { immortal = false } = {}) {
|
||||
let spawned = false;
|
||||
let killed = false;
|
||||
const liveServer = { ...server, pid: "9876" };
|
||||
return {
|
||||
scan: async () => (spawned && (immortal || !killed) ? [liveServer] : []),
|
||||
spawn: () => {
|
||||
spawned = true;
|
||||
return { pid: 4321, unref: vi.fn() };
|
||||
},
|
||||
sleep: async () => {},
|
||||
kill: vi.fn(() => {
|
||||
killed = true;
|
||||
}),
|
||||
stateHome,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectStaleSessionRemoved(stateHome: string): Promise<void> {
|
||||
const status = await readBackgroundPreviewStatus(projectDir, 3002, {
|
||||
scan: async () => [],
|
||||
@@ -105,6 +126,36 @@ describe("background preview lifecycle", () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws a port-mismatch error when the caller explicitly requests a port the reused server isn't on", async () => {
|
||||
const spawn = vi.fn();
|
||||
const scan = vi.fn(async () => [server]);
|
||||
|
||||
await expect(
|
||||
startBackgroundPreview(projectDir, 3002, {
|
||||
scan,
|
||||
spawn,
|
||||
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
|
||||
preferredPort: server.port + 1,
|
||||
}),
|
||||
).rejects.toThrow(PreviewServerPortMismatchError);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses normally when the caller's explicit port matches the reused server", async () => {
|
||||
const spawn = vi.fn();
|
||||
const scan = vi.fn(async () => [server]);
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, 3002, {
|
||||
scan,
|
||||
spawn,
|
||||
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
|
||||
preferredPort: server.port,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "reused", port: server.port });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers managed previews outside the default port scan and removes stale records", async () => {
|
||||
const stateHome = tempDir("hf-preview-state-");
|
||||
const otherProjectDir = resolve("/tmp/hyperframes-preview-managed-custom-port");
|
||||
@@ -385,6 +436,195 @@ describe("background preview lifecycle", () => {
|
||||
).toMatchObject({ pid: 4321 });
|
||||
});
|
||||
|
||||
it("reports the explicitly requested port when the detached child binds it", async () => {
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const dependencies = reachableChildDependencies(stateHome);
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, server.port, {
|
||||
...dependencies,
|
||||
preferredPort: server.port,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "started", port: server.port, pid: 9876 });
|
||||
expect(dependencies.kill).not.toHaveBeenCalled();
|
||||
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
|
||||
});
|
||||
|
||||
it("reaps a detached child that could not bind the explicitly requested port", async () => {
|
||||
// The child scans upward from --port and lands on the next free port.
|
||||
const requestedPort = server.port - 1;
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const dependencies = reachableChildDependencies(stateHome);
|
||||
|
||||
const launch = startBackgroundPreview(projectDir, requestedPort, {
|
||||
...dependencies,
|
||||
preferredPort: requestedPort,
|
||||
});
|
||||
|
||||
await expect(launch).rejects.toThrow(PreviewPortUnavailableError);
|
||||
await expect(launch).rejects.toMatchObject({ requestedPort, boundPort: server.port });
|
||||
// The wrapper PID is reaped, not the server's self-reported PID.
|
||||
expect(dependencies.kill).toHaveBeenCalledExactlyOnceWith(4321);
|
||||
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
|
||||
});
|
||||
|
||||
it("fails loudly when the reaped child keeps serving the substitute port", async () => {
|
||||
const requestedPort = server.port - 1;
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const dependencies = reachableChildDependencies(stateHome, { immortal: true });
|
||||
|
||||
await expect(
|
||||
startBackgroundPreview(projectDir, requestedPort, {
|
||||
...dependencies,
|
||||
preferredPort: requestedPort,
|
||||
}),
|
||||
).rejects.toThrow(/did not stop after failing to bind port/);
|
||||
expect(dependencies.kill).toHaveBeenCalledExactlyOnceWith(4321);
|
||||
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, server.port - 1, dependencies);
|
||||
|
||||
expect(result).toMatchObject({ type: "started", port: server.port });
|
||||
expect(dependencies.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the same-project server on the explicit port when several are running", async () => {
|
||||
const sibling = { ...server, port: server.port + 1, pid: "5555" };
|
||||
const spawn = vi.fn();
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, 3002, {
|
||||
scan: async () => [server, sibling],
|
||||
spawn,
|
||||
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
|
||||
preferredPort: sibling.port,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "reused", port: sibling.port, pid: 5555 });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the unmanaged sibling on the explicit port instead of rejecting against the owned server", async () => {
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const owned = { ...server, port: 3002 };
|
||||
const sibling = { ...server, port: 3003, pid: "5555" };
|
||||
writePreviewSession(
|
||||
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
|
||||
stateHome,
|
||||
);
|
||||
const spawn = vi.fn();
|
||||
const kill = vi.fn();
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, 3002, {
|
||||
kill,
|
||||
scan: async () => [owned, sibling],
|
||||
spawn,
|
||||
stateHome,
|
||||
preferredPort: sibling.port,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "reused", port: sibling.port, pid: 5555 });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists every same-project server in the port-mismatch error, not just the reuse candidate", async () => {
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const owned = { ...server, port: 3002 };
|
||||
const sibling = { ...server, port: 3003, pid: "5555" };
|
||||
const foreign = {
|
||||
...server,
|
||||
port: 3004,
|
||||
projectDir: resolve("/tmp/hyperframes-preview-lifecycle-other"),
|
||||
pid: "7777",
|
||||
};
|
||||
writePreviewSession(
|
||||
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
|
||||
stateHome,
|
||||
);
|
||||
const spawn = vi.fn();
|
||||
|
||||
const failure = await startBackgroundPreview(projectDir, 3002, {
|
||||
scan: async () => [owned, sibling, foreign],
|
||||
spawn,
|
||||
stateHome,
|
||||
preferredPort: 3004,
|
||||
}).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(PreviewServerPortMismatchError);
|
||||
expect(failure).toMatchObject({ requestedPort: 3004, ports: [3002, 3003] });
|
||||
expect((failure as Error).message).toContain("add --force-new or run --stop first");
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a same-project server on an explicit port outside the saved port's scan window", async () => {
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const owned = { ...server, port: 3002 };
|
||||
const farSibling = { ...server, port: 3500, pid: "5555" };
|
||||
writePreviewSession(
|
||||
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
|
||||
stateHome,
|
||||
);
|
||||
const spawn = vi.fn();
|
||||
const probe = vi.fn(async (port: number) => (port === farSibling.port ? farSibling : null));
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, farSibling.port, {
|
||||
scan: async () => [owned],
|
||||
probe,
|
||||
spawn,
|
||||
stateHome,
|
||||
preferredPort: farSibling.port,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "reused", port: farSibling.port, pid: 5555 });
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith(farSibling.port);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces the owned server on the explicit port when the GPU policy changes", async () => {
|
||||
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
|
||||
const owned = { ...server, port: 3002, browserGpuMode: "hardware" as const };
|
||||
const replacement = { ...server, port: 3002, pid: "5432", browserGpuMode: "software" as const };
|
||||
writePreviewSession(
|
||||
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
|
||||
stateHome,
|
||||
);
|
||||
let ownedRunning = true;
|
||||
let replacementRunning = false;
|
||||
const scan = vi.fn(async () => [
|
||||
...(ownedRunning ? [owned] : []),
|
||||
...(replacementRunning ? [replacement] : []),
|
||||
]);
|
||||
const kill = vi.fn((pid: number) => {
|
||||
if (pid === 4321) ownedRunning = false;
|
||||
});
|
||||
const spawn = vi.fn(() => {
|
||||
replacementRunning = true;
|
||||
return { pid: 5432, unref: vi.fn() };
|
||||
});
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, 3002, {
|
||||
browserGpuMode: "software",
|
||||
kill,
|
||||
scan,
|
||||
sleep: async () => {},
|
||||
spawn,
|
||||
stateHome,
|
||||
preferredPort: 3002,
|
||||
});
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(4321);
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ type: "started", port: 3002, pid: 5432 });
|
||||
});
|
||||
|
||||
it("reaps a detached child that never becomes reachable without recording ownership", async () => {
|
||||
const stateHome = tempDir("hf-preview-state-");
|
||||
const kill = vi.fn();
|
||||
|
||||
@@ -13,10 +13,30 @@ import {
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
|
||||
import {
|
||||
activeServerOnPort,
|
||||
MAX_PORT_SCAN,
|
||||
scanActiveServers,
|
||||
type ActiveServer,
|
||||
} from "../server/portUtils.js";
|
||||
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
import { isProcessDescendant, processIdentity } from "../utils/orphanCleanup.js";
|
||||
import { terminateProcessTree } from "../utils/processTree.js";
|
||||
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
|
||||
|
||||
export class PreviewPortUnavailableError extends Error {
|
||||
readonly requestedPort: number;
|
||||
readonly boundPort: number;
|
||||
|
||||
constructor(requestedPort: number, boundPort: number) {
|
||||
super(
|
||||
`Port ${requestedPort} is already in use; the background preview would have started on port ${boundPort} instead. Free port ${requestedPort}, pick another --port, or omit --port to accept the next free port.`,
|
||||
);
|
||||
this.name = "PreviewPortUnavailableError";
|
||||
this.requestedPort = requestedPort;
|
||||
this.boundPort = boundPort;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewSession {
|
||||
pid: number;
|
||||
@@ -47,9 +67,12 @@ interface LifecycleDependencies {
|
||||
isDescendant?: (childPid: number, ancestorPid: number) => boolean;
|
||||
identity?: (pid: number) => string | null;
|
||||
isSignalable?: (pid: number) => boolean;
|
||||
probe?: (port: number) => Promise<ActiveServer | null>;
|
||||
stateHome?: string;
|
||||
forceNew?: boolean;
|
||||
browserGpuMode?: BrowserGpuMode;
|
||||
/** Set only when the caller explicitly passed --port, not the CLI default. */
|
||||
preferredPort?: number;
|
||||
}
|
||||
|
||||
function defaultStateHome(): string {
|
||||
@@ -160,12 +183,16 @@ function matchingServer(
|
||||
projectDir: string,
|
||||
browserGpuMode?: BrowserGpuMode,
|
||||
): ActiveServer | null {
|
||||
return (
|
||||
servers.find(
|
||||
(server) =>
|
||||
normalized(server.projectDir) === normalized(projectDir) &&
|
||||
(browserGpuMode === undefined || server.browserGpuMode === browserGpuMode),
|
||||
) ?? null
|
||||
return policyMatchingServers(servers, projectDir, browserGpuMode)[0] ?? null;
|
||||
}
|
||||
|
||||
function policyMatchingServers(
|
||||
servers: ActiveServer[],
|
||||
projectDir: string,
|
||||
browserGpuMode?: BrowserGpuMode,
|
||||
): ActiveServer[] {
|
||||
return sameProjectServers(servers, projectDir).filter(
|
||||
(server) => browserGpuMode === undefined || server.browserGpuMode === browserGpuMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,13 +207,13 @@ function matchingServerAtPort(
|
||||
);
|
||||
}
|
||||
|
||||
function sameProjectPorts(servers: ActiveServer[], projectDir: string): Set<number> {
|
||||
function sameProjectServers(servers: ActiveServer[], projectDir: string): ActiveServer[] {
|
||||
const project = normalized(projectDir);
|
||||
return new Set(
|
||||
servers
|
||||
.filter((server) => normalized(server.projectDir) === project)
|
||||
.map((server) => server.port),
|
||||
);
|
||||
return servers.filter((server) => normalized(server.projectDir) === project);
|
||||
}
|
||||
|
||||
function sameProjectPorts(servers: ActiveServer[], projectDir: string): Set<number> {
|
||||
return new Set(sameProjectServers(servers, projectDir).map((server) => server.port));
|
||||
}
|
||||
|
||||
async function stopProcess(pid: number): Promise<void> {
|
||||
@@ -433,39 +460,80 @@ async function readPreviewLifecycleState(
|
||||
return { scan, stateHome, saved, scanStart, scanned };
|
||||
}
|
||||
|
||||
function unmetPreferredPort(port: number, preferredPort: number | undefined): number | undefined {
|
||||
return preferredPort !== undefined && port !== preferredPort ? preferredPort : undefined;
|
||||
}
|
||||
|
||||
type BackgroundPreviewResult =
|
||||
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
|
||||
| { type: "started"; port: number; pid: number; logPath: string };
|
||||
|
||||
function reuseExistingPreview(
|
||||
reusableExisting: ActiveServer | null,
|
||||
candidates: ActiveServer[],
|
||||
dependencies: LifecycleDependencies,
|
||||
): Extract<BackgroundPreviewResult, { type: "reused" }> | null {
|
||||
if (!reusableExisting || dependencies.forceNew) return null;
|
||||
const unmet = unmetPreferredPort(reusableExisting.port, dependencies.preferredPort);
|
||||
if (unmet !== undefined) {
|
||||
throw new PreviewServerPortMismatchError(
|
||||
unmet,
|
||||
candidates,
|
||||
` To start one on port ${unmet} instead, add --force-new or run --stop first.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "reused",
|
||||
port: reusableExisting.port,
|
||||
pid: reusableExisting.pid ? Number(reusableExisting.pid) : null,
|
||||
logPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
// The scan only covers scanStart..+MAX_PORT_SCAN-1; an explicit --port outside it is probed directly.
|
||||
async function addPreferredPortServer(
|
||||
scanned: ActiveServer[],
|
||||
scanStart: number,
|
||||
dependencies: LifecycleDependencies,
|
||||
): Promise<void> {
|
||||
const preferred = dependencies.preferredPort;
|
||||
if (preferred === undefined) return;
|
||||
if (preferred >= scanStart && preferred < scanStart + MAX_PORT_SCAN) return;
|
||||
const server = await (dependencies.probe ?? activeServerOnPort)(preferred);
|
||||
if (server) scanned.push(server);
|
||||
}
|
||||
|
||||
export async function startBackgroundPreview(
|
||||
projectDir: string,
|
||||
startPort: number,
|
||||
dependencies: LifecycleDependencies = {},
|
||||
): Promise<
|
||||
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
|
||||
| { type: "started"; port: number; pid: number; logPath: string }
|
||||
> {
|
||||
const { scan, stateHome, saved, scanned } = await readPreviewLifecycleState(
|
||||
): Promise<BackgroundPreviewResult> {
|
||||
const { scan, stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
|
||||
projectDir,
|
||||
startPort,
|
||||
dependencies,
|
||||
);
|
||||
await addPreferredPortServer(scanned, scanStart, dependencies);
|
||||
// Always inspect a saved custom port first. `--force-new --port <new>` must
|
||||
// replace that owned server before recording the replacement, otherwise the
|
||||
// single per-project ownership record would orphan the old listener.
|
||||
const requestedExisting = matchingServer(scanned, projectDir, dependencies.browserGpuMode);
|
||||
const candidates = policyMatchingServers(scanned, projectDir, dependencies.browserGpuMode);
|
||||
// A policy-matching server on the explicit --port satisfies it even when it is not the owned one,
|
||||
// so `--port 3003` reuses the 3003 sibling instead of reporting a mismatch against the owned 3002.
|
||||
const onPreferredPort =
|
||||
candidates.find((server) => server.port === dependencies.preferredPort) ?? null;
|
||||
const requestedExisting = candidates[0] ?? null;
|
||||
const ownedExisting = savedOwnedPreview(scanned, saved, projectDir);
|
||||
// A saved managed preview is the authoritative same-project instance. An
|
||||
// explicit GPU-policy change replaces it; it must not silently adopt an
|
||||
// unmanaged sibling that happens to match the new policy.
|
||||
// Otherwise a saved managed preview is the authoritative same-project
|
||||
// instance. An explicit GPU-policy change replaces it; it must not silently
|
||||
// adopt an unmanaged sibling that happens to match the new policy.
|
||||
const reusableOwned = ownedExisting
|
||||
? matchingServer([ownedExisting], projectDir, dependencies.browserGpuMode)
|
||||
: null;
|
||||
const reusableExisting = reusableOwned ?? (ownedExisting ? null : requestedExisting);
|
||||
if (reusableExisting && !dependencies.forceNew) {
|
||||
return {
|
||||
type: "reused",
|
||||
port: reusableExisting.port,
|
||||
pid: reusableExisting.pid ? Number(reusableExisting.pid) : null,
|
||||
logPath: null,
|
||||
};
|
||||
}
|
||||
const reusableExisting =
|
||||
onPreferredPort ?? reusableOwned ?? (ownedExisting ? null : requestedExisting);
|
||||
const reused = reuseExistingPreview(reusableExisting, candidates, dependencies);
|
||||
if (reused) return reused;
|
||||
await stopOwnedPreviewBeforeReplacement(ownedExisting, projectDir, dependencies);
|
||||
// Snapshot every same-project listener in the prospective launch range only
|
||||
// after the owned listener is gone. Readiness must identify a newly appeared
|
||||
@@ -478,6 +546,47 @@ export async function startBackgroundPreview(
|
||||
dependencies,
|
||||
);
|
||||
|
||||
const kill = dependencies.kill ?? stopProcess;
|
||||
const server = await awaitStartedServer(projectDir, startPort, preLaunchPorts, dependencies);
|
||||
if (!server) {
|
||||
await kill(pid);
|
||||
throw new Error(`background preview did not become ready; see ${logPath}`);
|
||||
}
|
||||
// The child scans upward from --port; one that missed an explicit --port is reaped, never recorded.
|
||||
const unmet = unmetPreferredPort(server.port, dependencies.preferredPort);
|
||||
if (unmet !== undefined) {
|
||||
// Wait until it stops answering so a concurrent launch or --status cannot adopt a dying server.
|
||||
await kill(pid);
|
||||
if (!(await awaitServerGone(projectDir, startPort, server.port, dependencies))) {
|
||||
throw new Error(
|
||||
`background preview on port ${server.port} did not stop after failing to bind port ${unmet}; see ${logPath}`,
|
||||
);
|
||||
}
|
||||
throw new PreviewPortUnavailableError(unmet, server.port);
|
||||
}
|
||||
const ready = readyPreviewSession(
|
||||
server,
|
||||
pid,
|
||||
wrapperIdentity,
|
||||
projectDir,
|
||||
logPath,
|
||||
dependencies,
|
||||
);
|
||||
writePreviewSession(ready.session, stateHome);
|
||||
return {
|
||||
type: "started",
|
||||
...ready.session,
|
||||
pid: ready.publicPid,
|
||||
};
|
||||
}
|
||||
|
||||
async function awaitStartedServer(
|
||||
projectDir: string,
|
||||
startPort: number,
|
||||
preLaunchPorts: Set<number>,
|
||||
dependencies: LifecycleDependencies,
|
||||
): Promise<ActiveServer | null> {
|
||||
const scan = dependencies.scan ?? scanActiveServers;
|
||||
const sleep = dependencies.sleep ?? delay;
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
const server = startedServer(
|
||||
@@ -486,27 +595,10 @@ export async function startBackgroundPreview(
|
||||
preLaunchPorts,
|
||||
dependencies.browserGpuMode,
|
||||
);
|
||||
if (server) {
|
||||
const ready = readyPreviewSession(
|
||||
server,
|
||||
pid,
|
||||
wrapperIdentity,
|
||||
projectDir,
|
||||
logPath,
|
||||
dependencies,
|
||||
);
|
||||
writePreviewSession(ready.session, stateHome);
|
||||
return {
|
||||
type: "started",
|
||||
...ready.session,
|
||||
pid: ready.publicPid,
|
||||
};
|
||||
}
|
||||
if (server) return server;
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
await (dependencies.kill ?? stopProcess)(pid);
|
||||
throw new Error(`background preview did not become ready; see ${logPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function stopBackgroundPreview(
|
||||
@@ -514,7 +606,7 @@ export async function stopBackgroundPreview(
|
||||
startPort: number,
|
||||
dependencies: LifecycleDependencies = {},
|
||||
): Promise<boolean> {
|
||||
const { scan, stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
|
||||
const { stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
|
||||
projectDir,
|
||||
startPort,
|
||||
dependencies,
|
||||
@@ -537,13 +629,24 @@ export async function stopBackgroundPreview(
|
||||
const kill = dependencies.kill ?? stopProcess;
|
||||
await kill(ownedStopTargetPid(saved, pid, dependencies));
|
||||
|
||||
if (!(await awaitServerGone(projectDir, scanStart, server.port, dependencies))) {
|
||||
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
|
||||
}
|
||||
removePreviewSession(projectDir, stateHome);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function awaitServerGone(
|
||||
projectDir: string,
|
||||
scanStart: number,
|
||||
port: number,
|
||||
dependencies: LifecycleDependencies,
|
||||
): Promise<boolean> {
|
||||
const scan = dependencies.scan ?? scanActiveServers;
|
||||
const sleep = dependencies.sleep ?? delay;
|
||||
for (let attempt = 0; attempt < 25; attempt++) {
|
||||
if (!matchingServerAtPort(await scan(scanStart), projectDir, server.port)) {
|
||||
removePreviewSession(projectDir, stateHome);
|
||||
return true;
|
||||
}
|
||||
if (!matchingServerAtPort(await scan(scanStart), projectDir, port)) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Max ports to scan before giving up. */
|
||||
const MAX_PORT_SCAN = 100;
|
||||
export const MAX_PORT_SCAN = 100;
|
||||
|
||||
/** Localhost HTTP probe timeout — HyperFrames responds in <1ms, so 300ms is generous. */
|
||||
const PROBE_TIMEOUT_MS = 300;
|
||||
|
||||
@@ -36,10 +36,10 @@ export class PreviewServerPortMismatchError extends Error {
|
||||
readonly requestedPort: number;
|
||||
readonly ports: number[];
|
||||
|
||||
constructor(requestedPort: number, servers: ActiveServer[]) {
|
||||
constructor(requestedPort: number, servers: ActiveServer[], hint = "") {
|
||||
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
|
||||
super(
|
||||
`No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.`,
|
||||
`No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.${hint}`,
|
||||
);
|
||||
this.name = "PreviewServerPortMismatchError";
|
||||
this.requestedPort = requestedPort;
|
||||
|
||||
Reference in New Issue
Block a user