fix(cli): scope explicit preview port discovery (#6242)

* fix(cli): scope explicit preview port discovery

* fix(cli): scope preview lifecycle discovery

* fix(cli): report the selected preview and its owned log
This commit is contained in:
Abdullah M. Nazif
2026-10-25 01:53:01 +00:00
committed by GitHub
parent 85fb19f1e8
commit e7169ae85b
5 changed files with 748 additions and 98 deletions
+25
View File
@@ -78,6 +78,31 @@ Run `docker info`.
Confirm Docker is installed, its daemon is running, your user has permission, and the first image download can reach the registry.
### Preview checks an unexpected port range
When starting a managed background preview, choose the discovery range with an explicit port:
```bash
npx hyperframes preview --background --port 4567
```
Discovery checks ports 4567–4666. If this project has a saved preview on another
port outside that range, only that saved port is checked separately. When moving
an existing managed preview to the requested port, add `--force-new` to stop and
replace it. Without `--port`, discovery starts at the saved preview's port.
`preview --status --port 4567` uses the same discovery range and checks an
out-of-range saved port separately. Among previews for this project, status
prefers the exact requested port, then the saved preview's port, then another
match. The saved log path appears only when the selected server is verified as
that background preview. If the listener PID cannot be determined, JSON reports
`pid: null` and text output omits the PID.
`preview --stop` checks only this project's saved port when a session exists,
even with an explicit `--port`. Without a saved session, `--stop --port 4567`
discovers previews in ports 4567–4666. Shutdown waits check only the port being
stopped.
## Media problems
### Video is black in preview but renders
+136
View File
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { runCommand } from "citty";
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
import { PreviewPortUnavailableError } from "./previewLifecycle.js";
import * as previewLifecycle from "./previewLifecycle.js";
import {
default as previewCommand,
backgroundStartFailureCode,
@@ -57,6 +58,141 @@ function tempProject(): string {
return dir;
}
describe.each(["status", "stop"] as const)("preview --%s port scope", (operation) => {
it.each([undefined, 32521])("forwards the explicit port %s", async (port) => {
const dir = tempProject();
writeFileSync(join(dir, "index.html"), "<html></html>");
const readStatus = vi
.spyOn(previewLifecycle, "readBackgroundPreviewStatus")
.mockResolvedValue(null);
const stop = vi.spyOn(previewLifecycle, "stopBackgroundPreview").mockResolvedValue(false);
vi.spyOn(console, "log").mockImplementation(() => {});
const rawArgs = [
dir,
`--${operation}`,
"--json",
...(port === undefined ? [] : ["--port", String(port)]),
];
const previousArgv = process.argv;
process.argv = [process.execPath, "cli.js", "preview", ...rawArgs];
try {
await runCommand(previewCommand, { rawArgs });
} finally {
process.argv = previousArgv;
}
const call = operation === "status" ? readStatus : stop;
expect(call).toHaveBeenCalledOnce();
const [project, startPort, options] = call.mock.calls[0] ?? [];
expect(project).toBe(dir);
expect(startPort).toBe(port ?? 3002);
expect(options?.preferredPort).toBe(port);
expect(operation === "status" ? stop : readStatus).not.toHaveBeenCalled();
});
});
describe("preview --status reporting", () => {
it("reports an unknown listener PID without borrowing the saved wrapper's PID", async () => {
const dir = tempProject();
writeFileSync(join(dir, "index.html"), "<html></html>");
vi.spyOn(previewLifecycle, "readBackgroundPreviewStatus").mockResolvedValue({
pid: null,
port: 32600,
projectDir: dir,
});
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(previewCommand, { rawArgs: [dir, "--status", "--json"] });
const result = JSON.parse(log.mock.calls.flat().join("\n")).result;
expect(result).toMatchObject({ state: "running", pid: null, mode: "unknown" });
expect(result).not.toHaveProperty("logPath");
});
it("lists an unverified endpoint without inventing its PID or background mode", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await handlePreviewList(3002, true, {
scan: async () => [],
listManaged: async () => [{ pid: null, port: 32600, projectDir: "/tmp/preview-unverified" }],
});
const result = JSON.parse(log.mock.calls.flat().join("\n")).result;
expect(result.sessions).toMatchObject([{ port: 32600, pid: null, mode: "unknown" }]);
});
it("keeps existing stop targets when status cannot confirm a listener PID", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const stopManaged = vi.fn(async () => true);
await handlePreviewKillAll(3002, true, {
listManaged: async () => [{ pid: null, port: 32600, projectDir: "/tmp/preview-unverified" }],
stopManaged,
killScanned: async () => ({ killed: 0, unverified: [] }),
});
expect(stopManaged).toHaveBeenCalledExactlyOnceWith("/tmp/preview-unverified", 32600);
});
it("retains scanned metadata when the same endpoint has an unknown saved-status PID", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await handlePreviewList(3002, true, {
scan: async () => [
{
pid: "9876",
pidSource: "os",
port: 32600,
host: "[::1]",
projectDir: "/tmp/preview-unverified",
projectName: "scanned-project",
version: "test",
},
],
listManaged: async () => [{ pid: null, port: 32600, projectDir: "/tmp/preview-unverified" }],
});
const result = JSON.parse(log.mock.calls.flat().join("\n")).result;
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
pid: 9876,
host: "[::1]",
projectName: "scanned-project",
mode: "unknown",
});
expect(result.sessions[0]).not.toHaveProperty("logPath");
});
it.each([true, false])("reports log ownership in JSON=%s", async (json) => {
const dir = tempProject();
writeFileSync(join(dir, "index.html"), "<html></html>");
const readStatus = vi.spyOn(previewLifecycle, "readBackgroundPreviewStatus");
const log = vi.spyOn(console, "log").mockImplementation(() => {});
for (const logPath of [undefined, "/tmp/owned-preview.log"]) {
readStatus.mockResolvedValue({ pid: 5555, port: 32530, projectDir: dir, logPath });
log.mockClear();
await runCommand(previewCommand, {
rawArgs: [dir, "--status", ...(json ? ["--json"] : [])],
});
const output = log.mock.calls.flat().join("\n");
if (json) {
const result = JSON.parse(output).result;
expect(result).toMatchObject({
state: "running",
pid: 5555,
port: 32530,
mode: logPath ? "background" : "unknown",
});
if (logPath) expect(result.logPath).toBe(logPath);
else expect(result).not.toHaveProperty("logPath");
} else if (logPath) {
expect(output).toContain(`Log: ${logPath}`);
expect(output).toContain("Background preview running");
} else {
expect(output).not.toContain("Log:");
expect(output).not.toContain("Background preview running");
expect(output).toContain("Preview running");
}
}
});
});
describe("Studio handoff URLs", () => {
it("hands off the exact timeline project route", () => {
expect(studioDeepLink("http://127.0.0.1:3002", "demo")).toBe(
+25 -8
View File
@@ -316,7 +316,9 @@ export default defineCommand({
try {
const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir);
if (args.stop) {
const stopped = await stopBackgroundPreview(project.dir, startPort);
const stopped = await stopBackgroundPreview(project.dir, startPort, {
preferredPort: explicitPort,
});
if (args.json) {
writeLifecycleJson(
lifecyclePayload(
@@ -335,7 +337,9 @@ export default defineCommand({
}
return;
}
const status = await readBackgroundPreviewStatus(project.dir, startPort);
const status = await readBackgroundPreviewStatus(project.dir, startPort, {
preferredPort: explicitPort,
});
if (!status) {
if (args.json) {
writeLifecycleJson(
@@ -355,7 +359,7 @@ export default defineCommand({
"status",
previewLifecycleSession({
state: "running",
mode: "background",
mode: status.logPath ? "background" : "unknown",
projectName: project.name,
projectDir: project.dir,
port: status.port,
@@ -367,7 +371,10 @@ export default defineCommand({
return;
}
printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, {
details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`],
details: [
`${status.logPath ? "Background preview" : "Preview"} running${status.pid === null ? "" : ` (PID ${status.pid})`}.`,
...(status.logPath ? [`Log: ${status.logPath}`] : []),
],
});
return;
} catch (error) {
@@ -728,17 +735,27 @@ export async function handlePreviewList(
(dependencies.scan ?? scanActiveServers)(startPort),
(dependencies.listManaged ?? listBackgroundPreviewStatuses)(),
]);
const listedSessions = managedSessions.filter(
(session) =>
session.pid !== null ||
!scannedServers.some(
(server) =>
publicPreviewPid(server.pid, null) !== null &&
server.port === session.port &&
resolve(server.projectDir) === resolve(session.projectDir),
),
);
const managedKeys = new Set(
managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`),
listedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`),
);
const servers = [
...managedSessions.map((session) => ({
...listedSessions.map((session) => ({
port: session.port,
host: "127.0.0.1",
projectName: basename(session.projectDir),
projectDir: session.projectDir,
version: "managed",
pid: String(session.pid),
version: session.logPath ? "managed" : "unknown",
pid: session.pid === null ? null : String(session.pid),
})),
...scannedServers.filter(
(server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`),
@@ -37,6 +37,116 @@ function savePreviewSession(stateHome: string): void {
);
}
describe("preview status selection and logs", () => {
const saved = {
pid: 4321,
wrapperIdentity: "wrapper-birth",
port: 32600,
projectDir,
logPath: "/tmp/managed-preview.log",
};
const managed: ActiveServer = { ...server, port: saved.port, pidSource: "os" };
it("prefers the saved preview over a lower sibling when the requested port is absent", async () => {
const stateHome = tempDir("hf-preview-status-");
writePreviewSession(saved, stateHome);
const before = readFileSync(previewSessionPath(projectDir, stateHome), "utf8");
const status = await readBackgroundPreviewStatus(projectDir, 32521, {
stateHome,
preferredPort: 32521,
scan: async () => [{ ...managed, port: 32530, pid: "5555" }, managed],
identity: () => saved.wrapperIdentity,
});
expect(status).toMatchObject({ port: 32600, pid: 4321, logPath: saved.logPath });
expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toBe(before);
});
it("prefers the exact requested endpoint regardless of the supplied discovery order", async () => {
const stateHome = tempDir("hf-preview-status-");
writePreviewSession(saved, stateHome);
const requested = { ...managed, port: 32580, pid: "8888" };
const status = await readBackgroundPreviewStatus(projectDir, requested.port, {
stateHome,
preferredPort: requested.port,
scan: async () => [{ ...managed, port: 32530, pid: "5555" }, managed, requested],
});
expect(status).toMatchObject({ port: requested.port, pid: 8888 });
expect(status).not.toHaveProperty("logPath");
});
it.each(["4321", "9876"])("keeps the log for a confirmed managed listener %s", async (pid) => {
const stateHome = tempDir("hf-preview-status-");
writePreviewSession(saved, stateHome);
const status = await readBackgroundPreviewStatus(projectDir, saved.port, {
stateHome,
scan: async () => [{ ...managed, pid }],
identity: () => saved.wrapperIdentity,
isDescendant: (child, parent) => child === 9876 && parent === saved.pid,
});
expect(status).toMatchObject({ pid: Number(pid), port: saved.port, logPath: saved.logPath });
});
it.each([
{ name: "another listener", pid: "5555", identity: "wrapper-birth", recorded: "wrapper-birth" },
{
name: "a recycled wrapper",
pid: "4321",
identity: "another-birth",
recorded: "wrapper-birth",
},
{ name: "an unavailable identity", pid: "4321", identity: null, recorded: "wrapper-birth" },
{ name: "a legacy record", pid: "4321", identity: null, recorded: undefined },
{
name: "an unknown listener PID",
pid: null,
identity: "wrapper-birth",
recorded: "wrapper-birth",
},
])("does not attach a managed log to $name", async ({ pid, identity, recorded }) => {
const stateHome = tempDir("hf-preview-status-");
writePreviewSession({ ...saved, wrapperIdentity: recorded }, stateHome);
const before = readFileSync(previewSessionPath(projectDir, stateHome), "utf8");
const status = await readBackgroundPreviewStatus(projectDir, saved.port, {
stateHome,
scan: async () => [{ ...managed, pid }],
identity: () => identity,
isDescendant: () => false,
});
expect(status).toMatchObject({ port: saved.port, pid: pid === null ? null : Number(pid) });
expect(status).not.toHaveProperty("logPath");
expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toBe(before);
});
it("does not trust a self-reported PID to associate a log", async () => {
const stateHome = tempDir("hf-preview-status-");
writePreviewSession(saved, stateHome);
const status = await readBackgroundPreviewStatus(projectDir, saved.port, {
stateHome,
scan: async () => [{ ...managed, pidSource: "self-reported" }],
identity: () => saved.wrapperIdentity,
});
expect(status).toMatchObject({ port: saved.port, pid: saved.pid });
expect(status).not.toHaveProperty("logPath");
});
it("reports an unmanaged endpoint without inventing a log path", async () => {
const stateHome = tempDir("hf-preview-status-");
const status = await readBackgroundPreviewStatus(projectDir, managed.port, {
stateHome,
scan: async () => [managed],
});
expect(status).toMatchObject({ port: managed.port, pid: saved.pid });
expect(status).not.toHaveProperty("logPath");
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
});
});
/** 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;
@@ -44,6 +154,8 @@ function reachableChildDependencies(stateHome: string, { immortal = false } = {}
const liveServer = { ...server, pid: "9876" };
return {
scan: async () => (spawned && (immortal || !killed) ? [liveServer] : []),
probe: async (port: number) =>
port === liveServer.port && spawned && (immortal || !killed) ? liveServer : null,
spawn: () => {
spawned = true;
return { pid: 4321, unref: vi.fn() };
@@ -137,10 +249,160 @@ describe("background preview lifecycle", () => {
});
expect(result).toMatchObject({ type: "reused", port: 41402, pid: 4321 });
expect(scan).toHaveBeenCalledWith(41402);
expect(scan).toHaveBeenCalledExactlyOnceWith(41402);
expect(spawn).not.toHaveBeenCalled();
});
it.each([3021, 32521, 32522, 32620, 32621])(
"bounds the explicit-port scan when the saved port is %s",
async (savedPort) => {
const stateHome = tempDir("hf-preview-state-");
const requestedPort = 32521;
const owned = { ...server, port: savedPort };
const requested = { ...server, port: requestedPort, pid: "5555" };
const servers = savedPort === requestedPort ? [requested] : [owned, requested];
writePreviewSession(
{ pid: 4321, port: savedPort, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
const scan = vi.fn(async (start = 3002) =>
servers.filter((candidate) => candidate.port >= start && candidate.port < start + 100),
);
const probe = vi.fn(
async (port: number) => servers.find((candidate) => candidate.port === port) ?? null,
);
const spawn = vi.fn();
const kill = vi.fn();
const result = await startBackgroundPreview(projectDir, requestedPort, {
scan,
probe,
spawn,
kill,
stateHome,
preferredPort: requestedPort,
});
expect(result).toMatchObject({ type: "reused", port: requestedPort, pid: 5555 });
expect(scan).toHaveBeenCalledExactlyOnceWith(requestedPort);
expect(probe.mock.calls).toEqual(
savedPort < requestedPort || savedPort > 32620 ? [[savedPort]] : [],
);
expect(spawn).not.toHaveBeenCalled();
expect(kill).not.toHaveBeenCalled();
},
);
it.each([true, false])(
"bounds the explicit-port replacement scan through shutdown (force-new: %s)",
async (forceNew) => {
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 3021, browserGpuMode: "hardware" as const };
const replacement = {
...server,
port: 32521,
pid: "5432",
browserGpuMode: "software" as const,
};
const sibling = { ...replacement, port: 32522, pid: "8765" };
writePreviewSession(
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
let ownedRunning = true;
let replacementRunning = false;
let stopping = false;
const scan = vi.fn(async (start = 3002) =>
[
sibling,
...(ownedRunning ? [owned] : []),
...(replacementRunning ? [replacement] : []),
].filter((candidate) => candidate.port >= start && candidate.port < start + 100),
);
const probe = vi.fn(async (port: number) =>
ownedRunning && port === owned.port ? owned : null,
);
const kill = vi.fn((pid: number) => {
expect(pid).toBe(4321);
stopping = true;
});
const sleep = vi.fn(async () => {
if (stopping) ownedRunning = false;
});
const spawn = vi.fn(() => {
expect(ownedRunning).toBe(false);
replacementRunning = true;
return { pid: 5432, unref: vi.fn() };
});
const result = await startBackgroundPreview(projectDir, replacement.port, {
browserGpuMode: "software",
forceNew,
preferredPort: replacement.port,
kill,
probe,
scan,
sleep,
spawn,
stateHome,
});
expect(result).toMatchObject({ type: "started", port: replacement.port, pid: 5432 });
expect(scan.mock.calls.length).toBeGreaterThan(1);
expect(scan.mock.calls.every(([port]) => port === replacement.port)).toBe(true);
expect(probe.mock.calls.length).toBeGreaterThanOrEqual(4);
expect(probe.mock.calls.every(([port]) => port === owned.port)).toBe(true);
expect(kill).toHaveBeenCalledExactlyOnceWith(4321);
expect(sleep).toHaveBeenCalledOnce();
expect(spawn).toHaveBeenCalledOnce();
expect(
JSON.parse(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")),
).toMatchObject({
port: replacement.port,
pid: 5432,
});
},
);
it("keeps explicit-port replacement blocked while the saved listener is still running", async () => {
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 3021 };
writePreviewSession(
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
const scan = vi.fn(async (start = 3002) =>
owned.port >= start && owned.port < start + 100 ? [owned] : [],
);
const probe = vi.fn(async (port: number) => (port === owned.port ? owned : null));
const spawn = vi.fn();
const kill = vi.fn();
await expect(
startBackgroundPreview(projectDir, 32521, {
forceNew: true,
preferredPort: 32521,
scan,
probe,
spawn,
kill,
sleep: async () => {},
stateHome,
}),
).rejects.toThrow("background preview did not stop");
expect(scan.mock.calls.every(([port]) => port === 32521)).toBe(true);
expect(probe.mock.calls.every(([port]) => port === owned.port)).toBe(true);
expect(kill).toHaveBeenCalledExactlyOnceWith(4321);
expect(spawn).not.toHaveBeenCalled();
expect(
JSON.parse(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")),
).toMatchObject({
port: owned.port,
pid: 4321,
});
});
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]);
@@ -178,6 +440,7 @@ describe("background preview lifecycle", () => {
writePreviewSession(
{
pid: 8765,
wrapperIdentity: "custom-wrapper-birth",
port: 41402,
projectDir: otherProjectDir,
logPath: "/tmp/custom.log",
@@ -196,6 +459,7 @@ describe("background preview lifecycle", () => {
const statuses = await listBackgroundPreviewStatuses({
stateHome,
identity: () => "custom-wrapper-birth",
scan: async (startPort) =>
startPort === 41402
? [
@@ -205,6 +469,7 @@ describe("background preview lifecycle", () => {
projectDir: otherProjectDir,
version: "test",
pid: "8765",
pidSource: "os",
},
]
: [],
@@ -262,6 +527,9 @@ describe("background preview lifecycle", () => {
const scan = vi.fn(async () =>
oldRunning ? [oldServer] : replacementRunning ? [replacement] : [],
);
const probe = vi.fn(async (port: number) =>
oldRunning && port === oldServer.port ? oldServer : null,
);
const kill = vi.fn((pid: number) => {
if (pid === 4321) oldRunning = false;
});
@@ -274,6 +542,7 @@ describe("background preview lifecycle", () => {
browserGpuMode: "software",
forceNew,
kill,
probe,
scan,
sleep: async () => {},
spawn,
@@ -315,6 +584,9 @@ describe("background preview lifecycle", () => {
sibling,
...(replacementRunning ? [replacement] : []),
]);
const probe = vi.fn(async (port: number) =>
ownedRunning && port === owned.port ? owned : port === sibling.port ? sibling : null,
);
const kill = vi.fn((pid: number) => {
if (pid === 4321) ownedRunning = false;
});
@@ -326,6 +598,7 @@ describe("background preview lifecycle", () => {
const result = await startBackgroundPreview(projectDir, replacement.port, {
browserGpuMode: "software",
kill,
probe,
scan,
sleep: async () => {},
spawn,
@@ -338,10 +611,6 @@ describe("background preview lifecycle", () => {
});
it("launches the replacement when the owned server died on its own", async () => {
// The owned preview crashes (or is Ctrl-C'd) between the outer scan and the
// one inside the stop. "Nothing left to stop" is the goal state for a
// replacement; treating it as fatal refused to start any preview at all
// until the session record was deleted by hand.
const stateHome = tempDir("hf-preview-state-");
const owned = { ...server, port: 41490 };
const replacement = { ...server, port: 41491, pid: "5432" };
@@ -353,7 +622,6 @@ describe("background preview lifecycle", () => {
let replacementRunning = false;
const scan = vi.fn(async () => {
scans += 1;
// Alive for the first look, gone by the time the stop path scans.
const ownedNow = scans === 1 ? [owned] : [];
return [...ownedNow, ...(replacementRunning ? [replacement] : [])];
});
@@ -366,6 +634,7 @@ describe("background preview lifecycle", () => {
const result = await startBackgroundPreview(projectDir, replacement.port, {
forceNew: true,
kill,
probe: async () => null,
scan,
sleep: async () => {},
spawn,
@@ -586,9 +855,10 @@ describe("background preview lifecycle", () => {
const spawn = vi.fn();
const kill = vi.fn();
const result = await startBackgroundPreview(projectDir, 3002, {
const result = await startBackgroundPreview(projectDir, sibling.port, {
kill,
scan: async () => [owned, sibling],
scan: async () => [sibling],
probe: async (port) => (port === owned.port ? owned : null),
spawn,
stateHome,
preferredPort: sibling.port,
@@ -602,10 +872,10 @@ describe("background preview lifecycle", () => {
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 sibling = { ...server, port: 3004, pid: "5555" };
const foreign = {
...server,
port: 3004,
port: 3003,
projectDir: resolve("/tmp/hyperframes-preview-lifecycle-other"),
pid: "7777",
};
@@ -615,18 +885,19 @@ describe("background preview lifecycle", () => {
);
const spawn = vi.fn();
const failure = await startBackgroundPreview(projectDir, 3002, {
scan: async () => [owned, sibling, foreign],
const failure = await startBackgroundPreview(projectDir, foreign.port, {
scan: async () => [sibling, foreign],
probe: async (port) => (port === owned.port ? owned : null),
spawn,
stateHome,
preferredPort: 3004,
preferredPort: foreign.port,
}).then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(PreviewServerPortMismatchError);
expect(failure).toMatchObject({ requestedPort: 3004, ports: [3002, 3003] });
expect(failure).toMatchObject({ requestedPort: 3003, ports: [3002, 3004] });
expect((failure as Error).message).toContain("add --force-new or run --stop first");
expect(spawn).not.toHaveBeenCalled();
});
@@ -640,10 +911,10 @@ describe("background preview lifecycle", () => {
stateHome,
);
const spawn = vi.fn();
const probe = vi.fn(async (port: number) => (port === farSibling.port ? farSibling : null));
const probe = vi.fn(async (port: number) => (port === owned.port ? owned : null));
const result = await startBackgroundPreview(projectDir, farSibling.port, {
scan: async () => [owned],
scan: async () => [farSibling],
probe,
spawn,
stateHome,
@@ -651,7 +922,7 @@ describe("background preview lifecycle", () => {
});
expect(result).toMatchObject({ type: "reused", port: farSibling.port, pid: 5555 });
expect(probe).toHaveBeenCalledExactlyOnceWith(farSibling.port);
expect(probe).toHaveBeenCalledExactlyOnceWith(owned.port);
expect(spawn).not.toHaveBeenCalled();
});
@@ -669,6 +940,9 @@ describe("background preview lifecycle", () => {
...(ownedRunning ? [owned] : []),
...(replacementRunning ? [replacement] : []),
]);
const probe = vi.fn(async (port: number) =>
port === owned.port && ownedRunning ? owned : null,
);
const kill = vi.fn((pid: number) => {
if (pid === 4321) ownedRunning = false;
});
@@ -680,6 +954,7 @@ describe("background preview lifecycle", () => {
const result = await startBackgroundPreview(projectDir, 3002, {
browserGpuMode: "software",
kill,
probe,
scan,
sleep: async () => {},
spawn,
@@ -754,27 +1029,38 @@ describe("background preview lifecycle", () => {
await expectStaleSessionRemoved(stateHome);
});
it("keeps a live preview's record when a single probe misses it", async () => {
// A server whose event loop is briefly blocked (a Puppeteer thumbnail
// capture will do it) answers nothing for a second or two. Retiring the
// record on that destroys the wrapperIdentity that is the only PID-reuse
// guard `--stop` has, and it never comes back.
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, wrapperIdentity: "posix:birth", port: 3210, projectDir, logPath: "/tmp/p.log" },
stateHome,
);
it.each([undefined, 32521])(
"keeps a live preview's record after a missed status probe (port: %s)",
async (preferredPort) => {
// A server whose event loop is briefly blocked (a Puppeteer thumbnail
// capture will do it) answers nothing for a second or two. Retiring the
// record on that destroys the wrapperIdentity that is the only PID-reuse
// guard `--stop` has, and it never comes back.
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{
pid: 4321,
wrapperIdentity: "posix:birth",
port: 3210,
projectDir,
logPath: "/tmp/p.log",
},
stateHome,
);
const status = await readBackgroundPreviewStatus(projectDir, 3002, {
scan: async () => [],
identity: () => "posix:birth",
isSignalable: () => true,
stateHome,
});
const status = await readBackgroundPreviewStatus(projectDir, preferredPort ?? 3002, {
scan: async () => [],
probe: async () => null,
identity: () => "posix:birth",
isSignalable: () => true,
stateHome,
preferredPort,
});
expect(status).toBeNull();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});
expect(status).toBeNull();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
},
);
it("keeps a live preview's record when the identity lookup gives no answer", async () => {
// `processIdentity` catches every failure into `null`, and on Windows and
@@ -863,26 +1149,149 @@ describe("background preview lifecycle", () => {
});
expect(status?.port).toBe(3210);
expect(scan).toHaveBeenCalledWith(3210);
expect(scan).toHaveBeenCalledExactlyOnceWith(3210);
});
it.each([3021, 32521, 32620, 32621])(
"bounds explicit status discovery when the saved port is %s",
async (savedPort) => {
const stateHome = tempDir("hf-preview-state-");
const requestedPort = 32521;
const savedServer = { ...server, port: savedPort };
const requested = { ...server, port: requestedPort, pid: "5555" };
const servers = savedPort === requestedPort ? [requested] : [savedServer, requested];
writePreviewSession(
{ pid: 4321, port: savedPort, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
const scan = vi.fn(async (start = 3002) =>
servers
.filter((candidate) => candidate.port >= start && candidate.port < start + 100)
.sort((a, b) => a.port - b.port),
);
const probe = vi.fn(
async (port: number) => servers.find((candidate) => candidate.port === port) ?? null,
);
const status = await readBackgroundPreviewStatus(projectDir, requestedPort, {
scan,
probe,
stateHome,
preferredPort: requestedPort,
});
expect(status).toMatchObject({ port: requestedPort, pid: 5555 });
expect(scan).toHaveBeenCalledExactlyOnceWith(requestedPort);
expect(probe.mock.calls).toEqual(
savedPort < requestedPort || savedPort > 32620 ? [[savedPort]] : [],
);
expect(
JSON.parse(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")),
).toMatchObject({
port: savedPort,
pid: 4321,
});
},
);
it("finds a saved preview outside the explicit status range without reporting another project", async () => {
const stateHome = tempDir("hf-preview-state-");
savePreviewSession(stateHome);
const scan = vi.fn(async () => [{ ...server, port: 32521, projectDir: `${projectDir}-other` }]);
const probe = vi.fn(async (port: number) => (port === server.port ? server : null));
const status = await readBackgroundPreviewStatus(projectDir, 32521, {
scan,
probe,
stateHome,
preferredPort: 32521,
});
expect(status).toMatchObject({ port: server.port, pid: 4321 });
expect(scan).toHaveBeenCalledExactlyOnceWith(32521);
expect(probe).toHaveBeenCalledExactlyOnceWith(server.port);
});
it.each([3021, 32521])(
"only probes the saved port %s for an explicit stop",
async (savedPort) => {
const stateHome = tempDir("hf-preview-state-");
writePreviewSession(
{ pid: 4321, port: savedPort, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
let running = true;
const owned = { ...server, port: savedPort };
const scan = vi.fn(async () => [{ ...server, port: 32522, pid: "8765" }]);
const probe = vi.fn(async (port: number) => (running && port === savedPort ? owned : null));
const kill = vi.fn(() => {
running = false;
});
expect(
await stopBackgroundPreview(projectDir, 32521, {
scan,
probe,
kill,
stateHome,
preferredPort: 32521,
}),
).toBe(true);
expect(scan).not.toHaveBeenCalled();
expect(probe.mock.calls).toEqual([[savedPort], [savedPort]]);
expect(kill).toHaveBeenCalledExactlyOnceWith(4321);
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
},
);
it("discovers within the explicit stop range when no session was saved", async () => {
const stateHome = tempDir("hf-preview-state-");
const discovered = { ...server, port: 32522 };
const scan = vi.fn(async () => [discovered]);
const probe = vi.fn(async () => null);
const kill = vi.fn();
expect(
await stopBackgroundPreview(projectDir, 32521, {
scan,
probe,
kill,
stateHome,
preferredPort: 32521,
}),
).toBe(true);
expect(scan).toHaveBeenCalledExactlyOnceWith(32521);
expect(probe).toHaveBeenCalledExactlyOnceWith(discovered.port);
expect(kill).toHaveBeenCalledExactlyOnceWith(4321);
});
it("stops only the matching project server and waits until it is unreachable", async () => {
let running = true;
let stopping = false;
const scan = vi.fn(async () => (running ? [server] : []));
const probe = vi.fn(async (port: number) => (running && port === server.port ? server : null));
const kill = vi.fn(() => {
running = false;
stopping = true;
});
const sleep = vi.fn(async () => {
if (stopping) running = false;
});
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
probe,
kill,
sleep: async () => {},
sleep,
stateHome: tempDir("hf-preview-state-"),
});
expect(result).toBe(true);
expect(kill).toHaveBeenCalledWith(4321);
expect(scan).toHaveBeenCalledTimes(2);
expect(scan).toHaveBeenCalledOnce();
expect(probe.mock.calls).toEqual([[server.port], [server.port]]);
expect(sleep).toHaveBeenCalledOnce();
});
it("does not kill an unmatched saved PID that may have been reused", async () => {
@@ -892,6 +1301,7 @@ describe("background preview lifecycle", () => {
const result = await stopBackgroundPreview(projectDir, 3002, {
scan: async () => [],
probe: async () => null,
kill,
stateHome,
});
@@ -913,6 +1323,7 @@ describe("background preview lifecycle", () => {
await expect(
stopBackgroundPreview(projectDir, 3002, {
scan,
probe: async () => ({ ...server, pid: null }),
kill,
sleep: async () => {},
stateHome,
@@ -942,6 +1353,7 @@ describe("background preview lifecycle", () => {
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
probe: async (port) => (running && port === server.port ? { ...server, pid: "9876" } : null),
kill,
isDescendant: (childPid, ancestorPid) => childPid === 9876 && ancestorPid === 4321,
identity: (pid) => (pid === 4321 ? "wrapper-birth" : null),
@@ -973,6 +1385,7 @@ describe("background preview lifecycle", () => {
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
probe: async (port) => (running && port === server.port ? { ...server, pid: "9876" } : null),
kill,
isDescendant: () => true,
identity: () => "reused-pid-birth",
@@ -994,6 +1407,7 @@ describe("background preview lifecycle", () => {
await expect(
stopBackgroundPreview(projectDir, 3002, {
scan: async () => [server],
probe: async () => server,
kill: vi.fn(),
sleep: async () => {},
stateHome,
@@ -1019,6 +1433,8 @@ describe("background preview lifecycle", () => {
const stopped = await stopBackgroundPreview(projectDir, owned.port, {
kill,
scan,
probe: async (port) =>
ownedRunning && port === owned.port ? owned : port === sibling.port ? sibling : null,
sleep: async () => {},
stateHome,
});
+106 -50
View File
@@ -47,6 +47,11 @@ export interface PreviewSession {
logPath: string;
}
type PreviewStatus = Pick<PreviewSession, "port" | "projectDir"> & {
pid: number | null;
logPath?: string;
};
type SpawnResult = {
pid?: number;
unref(): void;
@@ -302,25 +307,69 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] {
return [...filtered, "--foreground", "--no-open", "--force-new"];
}
function statusServer(
servers: ActiveServer[],
projectDir: string,
saved: PreviewSession | null,
preferredPort: number | undefined,
): ActiveServer | null {
const candidates = sameProjectServers(servers, projectDir);
return (
candidates.find((server) => server.port === preferredPort) ??
candidates.find((server) => server.port === saved?.port) ??
candidates[0] ??
null
);
}
function isCurrentPreviewWrapper(
saved: PreviewSession,
dependencies: LifecycleDependencies,
): boolean {
return (
!!saved.wrapperIdentity &&
(dependencies.identity ?? processIdentity)(saved.pid) === saved.wrapperIdentity
);
}
function managedStatusLogPath(
server: ActiveServer,
saved: PreviewSession | null,
pid: number,
dependencies: LifecycleDependencies,
): string | undefined {
if (!saved || saved.port !== server.port || server.pidSource !== "os") {
return undefined;
}
if (!isCurrentPreviewWrapper(saved, dependencies)) {
return undefined;
}
const isDescendant = dependencies.isDescendant ?? isProcessDescendant;
return pid === saved.pid || isDescendant(pid, saved.pid) ? saved.logPath : undefined;
}
export async function readBackgroundPreviewStatus(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<PreviewSession | null> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const server = matchingServer(await scan(saved?.port ?? startPort), projectDir);
): Promise<PreviewStatus | null> {
const { stateHome, saved, scanned } = await readPreviewLifecycleState(
projectDir,
startPort,
dependencies,
);
const server = statusServer(scanned, projectDir, saved, dependencies.preferredPort);
if (server) {
const pid = Number(server.pid ?? saved?.pid);
if (Number.isInteger(pid) && pid > 0) {
return {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath: saved?.logPath ?? previewLogPath(projectDir, stateHome),
};
}
const listenerPid = Number(server.pid);
const pid = Number.isInteger(listenerPid) && listenerPid > 0 ? listenerPid : null;
const logPath =
pid === null ? undefined : managedStatusLogPath(server, saved, pid, dependencies);
return {
pid,
port: server.port,
projectDir: resolve(projectDir),
...(logPath ? { logPath } : {}),
};
}
// A missed probe is not proof the preview is gone: a server whose event loop
@@ -383,7 +432,7 @@ function wrapperProcessIsAlive(
export async function listBackgroundPreviewStatuses(
dependencies: LifecycleDependencies = {},
): Promise<PreviewSession[]> {
): Promise<PreviewStatus[]> {
const stateHome = dependencies.stateHome ?? defaultStateHome();
const directory = sessionDirectory(stateHome);
let files: string[];
@@ -406,7 +455,7 @@ export async function listBackgroundPreviewStatuses(
}),
),
);
return statuses.filter((status): status is PreviewSession => status !== null);
return statuses.filter((status): status is PreviewStatus => status !== null);
}
function readyPreviewSession(
@@ -471,6 +520,21 @@ function savedOwnedPreview(
return matchingServer(savedPortServers, projectDir);
}
function isPortInScanRange(port: number, scanStart: number): boolean {
return port >= scanStart && port < scanStart + MAX_PORT_SCAN;
}
async function addSavedPortServer(
scanned: ActiveServer[],
savedPort: number | undefined,
scanStart: number,
dependencies: LifecycleDependencies,
): Promise<void> {
if (savedPort === undefined || isPortInScanRange(savedPort, scanStart)) return;
const server = await (dependencies.probe ?? activeServerOnPort)(savedPort);
if (server) scanned.push(server);
}
async function readPreviewLifecycleState(
projectDir: string,
startPort: number,
@@ -479,9 +543,10 @@ async function readPreviewLifecycleState(
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const scanStart = dependencies.preferredPort ?? saved?.port ?? startPort;
const scanned = await scan(scanStart);
return { scan, stateHome, saved, scanStart, scanned };
await addSavedPortServer(scanned, saved?.port, scanStart, dependencies);
return { scan, stateHome, saved, scanned };
}
function unmetPreferredPort(port: number, preferredPort: number | undefined): number | undefined {
@@ -514,33 +579,19 @@ function reuseExistingPreview(
};
}
// 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<BackgroundPreviewResult> {
const { scan, stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
const launchStart = dependencies.preferredPort ?? startPort;
const { scan, stateHome, saved, 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.
// A saved custom port stays eligible for replacement even outside the scan
// window, or overwriting the ownership record would orphan its listener.
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.
@@ -562,7 +613,7 @@ export async function startBackgroundPreview(
// Snapshot every same-project listener in the prospective launch range only
// after the owned listener is gone. Readiness must identify a newly appeared
// server, never a pre-existing unmanaged sibling.
const preLaunchPorts = sameProjectPorts(await scan(startPort), projectDir);
const preLaunchPorts = sameProjectPorts(await scan(launchStart), projectDir);
const { pid, wrapperIdentity, logPath, exitedWith } = spawnDetachedPreview(
projectDir,
@@ -573,7 +624,7 @@ export async function startBackgroundPreview(
const kill = dependencies.kill ?? stopProcess;
const server = await awaitStartedServer(
projectDir,
startPort,
launchStart,
preLaunchPorts,
pid,
exitedWith,
@@ -594,7 +645,7 @@ export async function startBackgroundPreview(
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))) {
if (!(await awaitServerGone(projectDir, server.port, dependencies))) {
throw new Error(
`background preview on port ${server.port} did not stop after failing to bind port ${unmet}; see ${logPath}`,
);
@@ -656,14 +707,12 @@ export async function stopBackgroundPreview(
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<boolean> {
const { stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
projectDir,
startPort,
dependencies,
);
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scan = dependencies.scan ?? scanActiveServers;
const server = saved
? matchingServerAtPort(scanned, projectDir, saved.port)
: matchingServer(scanned, projectDir);
? await probeMatchingServerAtPort(projectDir, saved.port, dependencies)
: matchingServer(await scan(dependencies.preferredPort ?? startPort), projectDir);
if (!server) {
removePreviewSession(projectDir, stateHome);
return false;
@@ -679,23 +728,30 @@ export async function stopBackgroundPreview(
const kill = dependencies.kill ?? stopProcess;
await kill(ownedStopTargetPid(saved, pid, dependencies));
if (!(await awaitServerGone(projectDir, scanStart, server.port, dependencies))) {
if (!(await awaitServerGone(projectDir, server.port, dependencies))) {
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
}
removePreviewSession(projectDir, stateHome);
return true;
}
async function probeMatchingServerAtPort(
projectDir: string,
port: number,
dependencies: LifecycleDependencies,
): Promise<ActiveServer | null> {
const server = await (dependencies.probe ?? activeServerOnPort)(port);
return matchingServerAtPort(server ? [server] : [], projectDir, port);
}
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, port)) return true;
if (!(await probeMatchingServerAtPort(projectDir, port, dependencies))) return true;
await sleep(100);
}
return false;