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:
miga-heygen
2026-10-02 18:26:05 +00:00
committed by GitHub
co-authored by Miguel Ángel
parent fc53908742
commit fcc637068c
6 changed files with 432 additions and 70 deletions
+11
View File
@@ -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(() => {});
+19 -11
View File
@@ -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();
+159 -56
View File
@@ -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;
}
+1 -1
View File
@@ -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;