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
19 changes: 10 additions & 9 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ 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 {
listBackgroundPreviewStatuses,
readBackgroundPreviewStatus,
Expand Down Expand Up @@ -296,7 +297,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 +386,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 +500,16 @@ 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));
const code =
error instanceof PreviewServerPortMismatchError
? "preview-port-mismatch"
: "preview-start-failed";
writeLifecycleJson(lifecycleFailurePayload("start", code, message));
} else {
clack.log.error(message);
}
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/commands/previewLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 {
buildBackgroundPreviewArgs,
listBackgroundPreviewStatuses,
Expand Down Expand Up @@ -99,6 +100,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
41 changes: 33 additions & 8 deletions packages/cli/src/commands/previewLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { dirname, join, resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
import { isProcessDescendant, killProcessTree, processIdentity } from "../utils/orphanCleanup.js";
import { PreviewServerPortMismatchError } from "../utils/studioSelectionClient.js";

export interface PreviewSession {
pid: number;
Expand Down Expand Up @@ -49,6 +50,8 @@ interface LifecycleDependencies {
stateHome?: string;
forceNew?: boolean;
browserGpuMode?: BrowserGpuMode;
/** Set only when the caller explicitly passed --port, not the CLI default. */
preferredPort?: number;
}

function defaultStateHome(): string {
Expand Down Expand Up @@ -426,6 +429,34 @@ function savedOwnedPreview(
return matchingServer(savedPortServers, projectDir);
}

/**
* Returns a reuse result when `reusableExisting` is a valid reuse candidate,
* or `null` when the caller must fall through to a fresh launch. Throws when
* the candidate's port conflicts with an explicit --port request rather than
* silently substituting the wrong port.
*/
function reuseExistingPreview(
reusableExisting: ActiveServer | null,
dependencies: LifecycleDependencies,
): { type: "reused"; port: number; pid: number | null; logPath: string | null } | null {
if (!reusableExisting || dependencies.forceNew) return null;
// An explicit --port that doesn't match the reuse candidate is a conflict
// the caller must resolve, not a silent substitution. A bare launch has no
// preferred port, so reusing any project-matching server stays correct.
if (
dependencies.preferredPort !== undefined &&
reusableExisting.port !== dependencies.preferredPort
) {
throw new PreviewServerPortMismatchError(dependencies.preferredPort, [reusableExisting]);
}
return {
type: "reused",
port: reusableExisting.port,
pid: reusableExisting.pid ? Number(reusableExisting.pid) : null,
logPath: null,
};
}

export async function startBackgroundPreview(
projectDir: string,
startPort: number,
Expand All @@ -451,14 +482,8 @@ export async function startBackgroundPreview(
? matchingServer([ownedExisting], projectDir, dependencies.browserGpuMode)
: null;
const reusableExisting = reusableOwned ?? (ownedExisting ? null : requestedExisting);
if (reusableExisting && !dependencies.forceNew) {
return {
type: "reused",
port: reusableExisting.port,
pid: reusableExisting.pid ? Number(reusableExisting.pid) : null,
logPath: null,
};
}
const reused = reuseExistingPreview(reusableExisting, dependencies);
if (reused) return reused;
await stopOwnedPreviewBeforeReplacement(ownedExisting, projectDir, dependencies);
// Snapshot every same-project listener in the prospective launch range only
// after the owned listener is gone. Readiness must identify a newly appeared
Expand Down
Loading