mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user