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