feat(preview): keep review sessions alive (#2384)

* feat(preview): keep review sessions alive

* fix(preview): harden background lifecycle state

* refactor(preview): satisfy lifecycle quality gate
This commit is contained in:
Miguel Ángel
2026-07-13 22:55:19 -04:00
committed by GitHub
parent 4495cb7355
commit 2a7a5e6236
8 changed files with 597 additions and 5 deletions
+89
View File
@@ -10,6 +10,9 @@ export const examples: Example[] = [
["Preview a specific project directory", "hyperframes preview ./my-video"],
["Use a custom port", "hyperframes preview --port 8080"],
["Force a new server even if one is already running", "hyperframes preview --force-new"],
["Keep preview running after this command exits", "hyperframes preview --background"],
["Show the background preview for this project", "hyperframes preview --status"],
["Stop the background preview for this project", "hyperframes preview --stop"],
["Start without opening the browser", "hyperframes preview --no-open"],
["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"],
[
@@ -44,6 +47,11 @@ import {
} from "../server/portUtils.js";
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";
import {
readBackgroundPreviewStatus,
startBackgroundPreview,
stopBackgroundPreview,
} from "./previewLifecycle.js";
interface BrowserLaunchOptions {
noOpen?: boolean;
@@ -90,6 +98,21 @@ export default defineCommand({
description: "Start a new server even if one is already running for this project",
default: false,
},
background: {
type: "boolean",
description: "Start an embedded preview that remains running after the command exits",
default: false,
},
status: {
type: "boolean",
description: "Show the background preview for this project and exit",
default: false,
},
stop: {
type: "boolean",
description: "Stop the background preview for this project and exit",
default: false,
},
list: {
type: "boolean",
description: "List all active preview servers and exit",
@@ -154,6 +177,30 @@ export default defineCommand({
const startPort = parseInt(args.port ?? "3002", 10);
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
if (args.status || args.stop) {
const project = resolveProject(args.dir);
if (args.stop) {
const stopped = await stopBackgroundPreview(project.dir, startPort);
console.log(
stopped
? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n`
: `\n ${c.dim("No background preview is running for")} ${project.dir}\n`,
);
return;
}
const status = await readBackgroundPreviewStatus(project.dir, startPort);
if (!status) {
console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`);
return;
}
console.log(`\n ${c.success("Background preview running")}`);
console.log(
` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`,
);
console.log(` ${c.dim(status.logPath)}\n`);
return;
}
// --list: scan and display active servers
if (args.list) {
const servers = await scanActiveServers(startPort);
@@ -267,6 +314,11 @@ export default defineCommand({
}
if (isDevMode()) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
return;
}
return runDevMode(dir, {
projectName,
noOpen,
@@ -279,6 +331,11 @@ export default defineCommand({
// If @hyperframes/studio is installed locally, use Vite for full HMR
if (hasLocalStudio(dir)) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
return;
}
return runLocalStudioMode(dir, {
projectName,
noOpen,
@@ -289,6 +346,38 @@ export default defineCommand({
});
}
if (args.background) {
let background;
try {
background = await startBackgroundPreview(dir, startPort, {
forceNew: Boolean(args["force-new"]),
});
} catch (error) {
clack.log.error(errorMessage(error));
process.exitCode = 1;
return;
}
const url = `http://localhost:${background.port}`;
clack.intro(c.bold("hyperframes preview"));
printStudioSummary(projectName, url, {
details: [
background.type === "reused"
? "Reusing the background server already running for this project."
: `Running in the background. Log: ${background.logPath}`,
"Changes reload automatically in the studio.",
],
footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`,
});
openStudioBrowser(url, projectName, {
noOpen,
browserPath,
userDataDir,
remoteDebuggingPort,
browserNoGpu,
});
return;
}
const forceNew = !!args["force-new"];
return runEmbeddedMode(dir, startPort, {
projectName,
@@ -0,0 +1,221 @@
import { existsSync, mkdtempSync } from "node:fs";
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 {
buildBackgroundPreviewArgs,
previewSessionPath,
readBackgroundPreviewStatus,
startBackgroundPreview,
stopBackgroundPreview,
writePreviewSession,
} from "./previewLifecycle.js";
const projectDir = resolve("/tmp/hyperframes-preview-lifecycle-project");
const server: ActiveServer = {
port: 3210,
projectName: "preview-lifecycle-project",
projectDir,
version: "test",
pid: "4321",
};
function savePreviewSession(stateHome: string): void {
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
}
async function expectStaleSessionRemoved(stateHome: string): Promise<void> {
const status = await readBackgroundPreviewStatus(projectDir, 3002, {
scan: async () => [],
stateHome,
});
expect(status).toBeNull();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
}
describe("background preview lifecycle", () => {
it("keeps case-distinct project paths separate on case-sensitive platforms", () => {
if (process.platform === "win32") return;
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
expect(previewSessionPath("/tmp/Project", stateHome)).not.toBe(
previewSessionPath("/tmp/project", stateHome),
);
});
it("builds a detached child invocation without recursively preserving --background", () => {
expect(
buildBackgroundPreviewArgs([
"/opt/hyperframes/cli.js",
"preview",
projectDir,
"--background",
"--open",
]),
).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]);
});
it("reuses an already-running server for the same project", async () => {
const spawn = vi.fn();
const scan = vi.fn(async () => [server]);
const result = await startBackgroundPreview(projectDir, 3002, {
argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"],
execPath: "/usr/bin/node",
scan,
spawn,
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toMatchObject({ type: "reused", port: 3210 });
expect(spawn).not.toHaveBeenCalled();
});
it("force-new waits for a different server instead of reusing the existing one", async () => {
const replacement = { ...server, port: 3211, pid: "5432" };
let scans = 0;
const scan = vi.fn(async () => (++scans < 3 ? [server] : [server, replacement]));
const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() }));
const result = await startBackgroundPreview(projectDir, 3002, {
forceNew: true,
scan,
spawn,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 });
expect(spawn).toHaveBeenCalledOnce();
});
it("returns after a detached child becomes reachable and records its session", async () => {
let scans = 0;
const scan = vi.fn(async () => (++scans < 2 ? [] : [server]));
const unref = vi.fn();
const spawn = vi.fn(() => ({ pid: 4321, unref }));
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
const result = await startBackgroundPreview(projectDir, 3002, {
argv: ["/opt/hyperframes/cli.js", "preview", projectDir, "--background"],
execPath: "/usr/bin/node",
scan,
spawn,
sleep: async () => {},
stateHome,
});
expect(result).toMatchObject({ type: "started", port: 3210, pid: 4321 });
expect(unref).toHaveBeenCalledOnce();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});
it("removes a stale session when no matching server or process survives", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 999_999, port: 3210, projectDir, logPath: "/tmp/missing.log" },
stateHome,
);
await expectStaleSessionRemoved(stateHome);
});
it("removes stale session metadata when its PID is alive but no server proves ownership", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
await expectStaleSessionRemoved(stateHome);
});
it("uses the recorded custom port when status is called without repeating --port", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
const scan = vi.fn(async () => [server]);
const status = await readBackgroundPreviewStatus(projectDir, 3002, { scan, stateHome });
expect(status?.port).toBe(3210);
expect(scan).toHaveBeenCalledWith(3210);
});
it("stops only the matching project server and waits until it is unreachable", async () => {
let running = true;
const scan = vi.fn(async () => (running ? [server] : []));
const kill = vi.fn(() => {
running = false;
});
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
kill,
sleep: async () => {},
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
});
expect(result).toBe(true);
expect(kill).toHaveBeenCalledWith(4321);
expect(scan).toHaveBeenCalledTimes(2);
});
it("does not kill an unmatched saved PID that may have been reused", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
savePreviewSession(stateHome);
const kill = vi.fn();
const result = await stopBackgroundPreview(projectDir, 3002, {
scan: async () => [],
kill,
stateHome,
});
expect(result).toBe(false);
expect(kill).not.toHaveBeenCalled();
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false);
});
it("uses the saved child PID when a matching live server cannot report one", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
let running = true;
const scan = vi.fn(async () => (running ? [{ ...server, pid: null }] : []));
const kill = vi.fn(() => {
running = false;
});
const result = await stopBackgroundPreview(projectDir, 3002, {
scan,
kill,
sleep: async () => {},
stateHome,
});
expect(result).toBe(true);
expect(kill).toHaveBeenCalledWith(4321);
});
it("fails loudly when the server remains reachable after stop", async () => {
const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-"));
writePreviewSession(
{ pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" },
stateHome,
);
await expect(
stopBackgroundPreview(projectDir, 3002, {
scan: async () => [server],
kill: vi.fn(),
sleep: async () => {},
stateHome,
}),
).rejects.toThrow(/did not stop/i);
expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true);
});
});
@@ -0,0 +1,267 @@
import { spawn as nodeSpawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import { killProcessTree } from "../utils/orphanCleanup.js";
export interface PreviewSession {
pid: number;
port: number;
projectDir: string;
logPath: string;
}
type SpawnResult = { pid?: number; unref(): void };
type SpawnPreview = (
command: string,
args: string[],
options: {
detached: boolean;
stdio: ["ignore", number, number];
env: NodeJS.ProcessEnv;
},
) => SpawnResult;
interface LifecycleDependencies {
argv?: string[];
execPath?: string;
scan?: (startPort?: number) => Promise<ActiveServer[]>;
spawn?: SpawnPreview;
sleep?: (ms: number) => Promise<void>;
kill?: (pid: number) => void;
stateHome?: string;
forceNew?: boolean;
}
function defaultStateHome(): string {
return process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
}
function normalized(path: string): string {
const resolved = resolve(path).replace(/\\/g, "/");
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function sessionDirectory(stateHome = defaultStateHome()): string {
return join(stateHome, "hyperframes", "previews");
}
export function previewSessionPath(projectDir: string, stateHome = defaultStateHome()): string {
const key = createHash("sha256").update(normalized(projectDir)).digest("hex").slice(0, 16);
return join(sessionDirectory(stateHome), `${key}.json`);
}
function previewLogPath(projectDir: string, stateHome = defaultStateHome()): string {
return previewSessionPath(projectDir, stateHome).replace(/\.json$/, ".log");
}
export function writePreviewSession(session: PreviewSession, stateHome = defaultStateHome()): void {
const path = previewSessionPath(session.projectDir, stateHome);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
}
function readPreviewSession(
projectDir: string,
stateHome = defaultStateHome(),
): PreviewSession | null {
const path = previewSessionPath(projectDir, stateHome);
if (!existsSync(path)) return null;
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession;
if (
!Number.isInteger(parsed.pid) ||
parsed.pid <= 0 ||
normalized(parsed.projectDir) !== normalized(projectDir)
) {
throw new Error("invalid preview session");
}
return parsed;
} catch {
rmSync(path, { force: true });
return null;
}
}
function removePreviewSession(projectDir: string, stateHome = defaultStateHome()): void {
rmSync(previewSessionPath(projectDir, stateHome), { force: true });
}
function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null {
return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null;
}
function stopProcess(pid: number): void {
killProcessTree(pid);
if (process.platform === "win32") {
try {
process.kill(pid, "SIGTERM");
} catch {
// Process already exited.
}
}
}
const delay = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
function spawnDetachedPreview(
projectDir: string,
stateHome: string,
dependencies: LifecycleDependencies,
): { pid: number; logPath: string } {
const logPath = previewLogPath(projectDir, stateHome);
mkdirSync(dirname(logPath), { recursive: true });
const logFd = openSync(logPath, "a", 0o600);
const spawn = dependencies.spawn ?? (nodeSpawn as unknown as SpawnPreview);
let child: SpawnResult;
try {
child = spawn(
dependencies.execPath ?? process.execPath,
buildBackgroundPreviewArgs(dependencies.argv ?? process.argv.slice(1)),
{
detached: true,
stdio: ["ignore", logFd, logFd],
env: process.env,
},
);
} finally {
closeSync(logFd);
}
if (!child.pid) throw new Error("background preview child did not report a PID");
child.unref();
return { pid: child.pid, logPath };
}
function startedServer(
servers: ActiveServer[],
projectDir: string,
existing: ActiveServer | null,
forceNew: boolean,
): ActiveServer | null {
const candidates =
forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers;
return matchingServer(candidates, projectDir);
}
export function buildBackgroundPreviewArgs(argv: string[]): string[] {
const filtered = argv.filter(
(arg) =>
arg !== "--background" &&
!arg.startsWith("--background=") &&
arg !== "--open" &&
arg !== "--no-open",
);
return [...filtered, "--no-open"];
}
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);
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),
};
}
}
removePreviewSession(projectDir, stateHome);
return null;
}
export async function startBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
| { type: "started"; port: number; pid: number; logPath: string }
> {
const scan = dependencies.scan ?? scanActiveServers;
const existing = matchingServer(await scan(startPort), projectDir);
if (existing && !dependencies.forceNew) {
return {
type: "reused",
port: existing.port,
pid: existing.pid ? Number(existing.pid) : null,
logPath: null,
};
}
const stateHome = dependencies.stateHome ?? defaultStateHome();
const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 50; attempt++) {
const server = startedServer(
await scan(startPort),
projectDir,
existing,
dependencies.forceNew === true,
);
if (server) {
const session = {
pid,
port: server.port,
projectDir: resolve(projectDir),
logPath,
};
writePreviewSession(session, stateHome);
return { type: "started", ...session };
}
await sleep(200);
}
(dependencies.kill ?? stopProcess)(pid);
throw new Error(`background preview did not become ready; see ${logPath}`);
}
export async function stopBackgroundPreview(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<boolean> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const server = matchingServer(await scan(scanStart), projectDir);
// A saved PID can be reused after a crashed preview, so only trust it while
// a currently reachable server proves this exact project is still running.
const pid = Number(server ? (server.pid ?? saved?.pid) : undefined);
if (!Number.isInteger(pid) || pid <= 0) {
removePreviewSession(projectDir, stateHome);
return false;
}
(dependencies.kill ?? stopProcess)(pid);
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 25; attempt++) {
if (!matchingServer(await scan(scanStart), projectDir)) {
removePreviewSession(projectDir, stateHome);
return true;
}
await sleep(100);
}
throw new Error(`background preview did not stop for ${resolve(projectDir)}`);
}
+5 -1
View File
@@ -97,6 +97,7 @@ export async function testPortOnAllHosts(
interface HyperframesConfigResponse {
isHyperframes: boolean;
pid?: number;
projectName: string;
projectDir: string;
serverBuildSignature?: string | null;
@@ -278,7 +279,10 @@ export async function scanActiveServers(startPort = 3002): Promise<ActiveServer[
ports.map(async (port) => {
const config = await probePort(port);
if (!config) return null;
const pid = await getProcessOnPort(port);
const pid =
Number.isInteger(config.pid) && Number(config.pid) > 0
? String(config.pid)
: await getProcessOnPort(port);
return {
port,
projectName: config.projectName,
+1
View File
@@ -600,6 +600,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const serverBuildSignature = await loadPreviewServerBuildSignature();
return c.json({
isHyperframes: true,
pid: process.pid,
projectName: projectId,
projectDir: projectDir,
serverBuildSignature,