Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.j
import { resolveProject, resolveProjectOrThrow } from "../utils/project.js";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import { studioProxyEnv } from "../utils/studioProxyEnv.js";
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
import {
PreviewPortUnavailableError,
listBackgroundPreviewStatuses,
readBackgroundPreviewStatus,
startBackgroundPreview,
Expand Down Expand Up @@ -126,6 +128,16 @@ type CompactSelectionPayload = Pick<

const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];

/** JSON error code for an explicit --port that no same-project server is on. */
const PREVIEW_PORT_MISMATCH_CODE = "preview-port-mismatch";

/** Distinguishes an unhonoured explicit --port from a generic launch failure. */
function backgroundStartFailureCode(error: unknown): string {
if (error instanceof PreviewServerPortMismatchError) return PREVIEW_PORT_MISMATCH_CODE;
if (error instanceof PreviewPortUnavailableError) return "preview-port-unavailable";
return "preview-start-failed";
}

export default defineCommand({
meta: {
name: "preview",
Expand Down Expand Up @@ -296,7 +308,7 @@ export default defineCommand({
if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware";
if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software";
const startPort = parseInt(args.port ?? "3002", 10);
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
const explicitPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;

if (args.status || args.stop) {
try {
Expand Down Expand Up @@ -385,18 +397,13 @@ export default defineCommand({
json: Boolean(args.json),
fields: args["context-fields"] as string | undefined,
detail: args["context-detail"] as string | undefined,
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
preferredPort: explicitPort,
});
}

if (args.selection) {
const project = resolveProject(args.dir);
return printCurrentSelection(
project.dir,
startPort,
Boolean(args.json),
preferredContextPort,
);
return printCurrentSelection(project.dir, startPort, Boolean(args.json), explicitPort);
}

const rawArg = args.dir;
Expand Down Expand Up @@ -504,11 +511,14 @@ export default defineCommand({
// the existing managed server resolved earlier. Only an explicit
// --browser-gpu/--no-browser-gpu request authorizes replacement.
browserGpuMode: args["browser-gpu"] === undefined ? undefined : browserGpuMode,
preferredPort: explicitPort,
});
} catch (error) {
const message = errorMessage(error);
if (args.json) {
writeLifecycleJson(lifecycleFailurePayload("start", "preview-start-failed", message));
writeLifecycleJson(
lifecycleFailurePayload("start", backgroundStartFailureCode(error), message),
);
} else {
clack.log.error(message);
}
Expand Down Expand Up @@ -904,7 +914,7 @@ async function printCurrentSelection(
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, json);
printSelectionFailure(PREVIEW_PORT_MISMATCH_CODE, err.message, json);
return;
}
throw err;
Expand Down Expand Up @@ -1026,7 +1036,7 @@ async function printCurrentContext(
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, options.json);
printSelectionFailure(PREVIEW_PORT_MISMATCH_CODE, err.message, options.json);
return;
}
throw err;
Expand Down
218 changes: 218 additions & 0 deletions packages/cli/src/commands/previewLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ActiveServer } from "../server/portUtils.js";
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";
import {
PreviewPortUnavailableError,
buildBackgroundPreviewArgs,
listBackgroundPreviewStatuses,
previewSessionPath,
Expand All @@ -29,6 +31,28 @@ function savePreviewSession(stateHome: string): void {
);
}

/**
* Launch dependencies whose detached wrapper (PID 4321) brings up `server`
* (its own PID 9876) once spawned, and takes it down again when killed.
*/
function reachableChildDependencies(stateHome: string, { immortal = false } = {}) {
let spawned = false;
let killed = false;
const liveServer = { ...server, pid: "9876" };
return {
scan: async () => (spawned && (immortal || !killed) ? [liveServer] : []),
spawn: () => {
spawned = true;
return { pid: 4321, unref: vi.fn() };
},
sleep: async () => {},
kill: vi.fn(() => {
killed = true;
}),
stateHome,
};
}

async function expectStaleSessionRemoved(stateHome: string): Promise<void> {
const status = await readBackgroundPreviewStatus(projectDir, 3002, {
scan: async () => [],
Expand Down Expand Up @@ -99,6 +123,36 @@ describe("background preview lifecycle", () => {
expect(spawn).not.toHaveBeenCalled();
});

it("throws a port-mismatch error when the caller explicitly requests a port the reused server isn't on", async () => {
const spawn = vi.fn();
const scan = vi.fn(async () => [server]);

await expect(
startBackgroundPreview(projectDir, 3002, {
scan,
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
preferredPort: server.port + 1,
}),
).rejects.toThrow(PreviewServerPortMismatchError);
expect(spawn).not.toHaveBeenCalled();
});

it("reuses normally when the caller's explicit port matches the reused server", async () => {
const spawn = vi.fn();
const scan = vi.fn(async () => [server]);

const result = await startBackgroundPreview(projectDir, 3002, {
scan,
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
preferredPort: server.port,
});

expect(result).toMatchObject({ type: "reused", port: server.port });
expect(spawn).not.toHaveBeenCalled();
});

it("discovers managed previews outside the default port scan and removes stale records", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const otherProjectDir = resolve("/tmp/hyperframes-preview-managed-custom-port");
Expand Down Expand Up @@ -379,6 +433,170 @@ describe("background preview lifecycle", () => {
).toMatchObject({ pid: 4321 });
});

it("reports the explicitly requested port when the detached child binds it", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const dependencies = reachableChildDependencies(stateHome);

const result = await startBackgroundPreview(projectDir, server.port, {
...dependencies,
preferredPort: server.port,
});

expect(result).toMatchObject({ type: "started", port: server.port, pid: 9876 });
expect(dependencies.kill).not.toHaveBeenCalled();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});

it("reaps a detached child that could not bind the explicitly requested port", async () => {
// The child scans upward from --port and lands on the next free port.
const requestedPort = server.port - 1;
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const dependencies = reachableChildDependencies(stateHome);

const launch = startBackgroundPreview(projectDir, requestedPort, {
...dependencies,
preferredPort: requestedPort,
});

await expect(launch).rejects.toThrow(PreviewPortUnavailableError);
await expect(launch).rejects.toMatchObject({ requestedPort, boundPort: server.port });
// The wrapper PID is reaped, not the server's self-reported PID.
expect(dependencies.kill).toHaveBeenCalledExactlyOnceWith(4321);
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
});

it("fails loudly when the reaped child keeps serving the substitute port", async () => {
const requestedPort = server.port - 1;
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const dependencies = reachableChildDependencies(stateHome, { immortal: true });

await expect(
startBackgroundPreview(projectDir, requestedPort, {
...dependencies,
preferredPort: requestedPort,
}),
).rejects.toThrow(/did not stop after failing to bind port/);
expect(dependencies.kill).toHaveBeenCalledExactlyOnceWith(4321);
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
});

it("keeps the next free port when no explicit port was requested", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const dependencies = reachableChildDependencies(stateHome);

const result = await startBackgroundPreview(projectDir, server.port - 1, dependencies);

expect(result).toMatchObject({ type: "started", port: server.port });
expect(dependencies.kill).not.toHaveBeenCalled();
});

it("reuses the same-project server on the explicit port when several are running", async () => {
const sibling = { ...server, port: server.port + 1, pid: "5555" };
const spawn = vi.fn();

const result = await startBackgroundPreview(projectDir, 3002, {
scan: async () => [server, sibling],
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
preferredPort: sibling.port,
});

expect(result).toMatchObject({ type: "reused", port: sibling.port, pid: 5555 });
expect(spawn).not.toHaveBeenCalled();
});

it("reuses the unmanaged sibling on the explicit port instead of rejecting against the owned server", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const owned = { ...server, port: 3002 };
const sibling = { ...server, port: 3003, pid: "5555" };
writePreviewSession(
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
const spawn = vi.fn();
const kill = vi.fn();

const result = await startBackgroundPreview(projectDir, 3002, {
kill,
scan: async () => [owned, sibling],
spawn,
stateHome,
preferredPort: sibling.port,
});

expect(result).toMatchObject({ type: "reused", port: sibling.port, pid: 5555 });
expect(spawn).not.toHaveBeenCalled();
expect(kill).not.toHaveBeenCalled();
});

it("lists every same-project server in the port-mismatch error, not just the reuse candidate", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const owned = { ...server, port: 3002 };
const sibling = { ...server, port: 3003, pid: "5555" };
const foreign = {
...server,
port: 3004,
projectDir: resolve("/tmp/hyperframes-preview-lifecycle-other"),
pid: "7777",
};
writePreviewSession(
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
const spawn = vi.fn();

const failure = await startBackgroundPreview(projectDir, 3002, {
scan: async () => [owned, sibling, foreign],
spawn,
stateHome,
preferredPort: 3004,
}).then(
() => null,
(error: unknown) => error,
);

expect(failure).toBeInstanceOf(PreviewServerPortMismatchError);
expect(failure).toMatchObject({ requestedPort: 3004, ports: [3002, 3003] });
expect(spawn).not.toHaveBeenCalled();
});

it("replaces the owned server on the explicit port when the GPU policy changes", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const owned = { ...server, port: 3002, browserGpuMode: "hardware" as const };
const replacement = { ...server, port: 3002, pid: "5432", browserGpuMode: "software" as const };
writePreviewSession(
{ pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
let ownedRunning = true;
let replacementRunning = false;
const scan = vi.fn(async () => [
...(ownedRunning ? [owned] : []),
...(replacementRunning ? [replacement] : []),
]);
const kill = vi.fn((pid: number) => {
if (pid === 4321) ownedRunning = false;
});
const spawn = vi.fn(() => {
replacementRunning = true;
return { pid: 5432, unref: vi.fn() };
});

const result = await startBackgroundPreview(projectDir, 3002, {
browserGpuMode: "software",
kill,
scan,
sleep: async () => {},
spawn,
stateHome,
preferredPort: 3002,
});

expect(kill).toHaveBeenCalledWith(4321);
expect(spawn).toHaveBeenCalledOnce();
expect(result).toMatchObject({ type: "started", port: 3002, pid: 5432 });
});

it("reaps a detached child that never becomes reachable without recording ownership", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const kill = vi.fn();
Expand Down
Loading
Loading