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
36 changes: 24 additions & 12 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,17 @@ export default defineCommand({
console.log("\n No active preview servers to kill.\n");
return;
}
const killed = await killActiveServers(startPort);
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`);
const { killed, unverified } = await killActiveServers(startPort);
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.`);
if (unverified.length > 0) {
clack.log.warn(
`Left ${unverified.length} server${unverified.length === 1 ? "" : "s"} alone ` +
`(port${unverified.length === 1 ? "" : "s"} ${unverified.join(", ")}): the OS could ` +
`not confirm which process owns the socket, and the server's own claim is not proof. ` +
`Install lsof, or stop it with its own preview --stop.`,
);
}
console.log();
return;
}

Expand All @@ -287,14 +296,6 @@ export default defineCommand({
);
}

// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
const orphansKilled = killOrphanedProcesses();
if (orphansKilled > 0) {
console.log(
` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`,
);
}

const rawArg = args.dir;
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
const project = resolveProject(rawArg);
Expand Down Expand Up @@ -352,6 +353,17 @@ export default defineCommand({
// modes all receive identical --proxy/--no-proxy + config semantics.
const autoProxy = resolveAutoProxy(dir, args.proxy as boolean | undefined);

// Kill orphaned chrome-headless-shell processes from previous crashed
// sessions. Deliberately last: this reaches outside the process and kills
// other people's PIDs, so it must not run for an invocation that turns out
// to be a validation error and never starts anything.
const orphansKilled = killOrphanedProcesses();
if (orphansKilled > 0) {
console.log(
` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`,
);
}

if (isDevMode()) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
Expand Down Expand Up @@ -969,8 +981,8 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
// SIGINT to the foreground process group (covers the common case), but
// `kill <pid>` only targets this process — the child tree (Vite + Chrome)
// would survive without explicit cleanup.
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
// propagates via the console process group instead.
// On Windows, killProcessTree delegates to taskkill's tree mode, which force
// kills the whole tree immediately — no grace period, unlike the POSIX path.
registerChildTreeShutdown(child);
return waitForChildClose(child);
}
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/src/server/portUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createServer, type Server } from "node:net";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
import {
PORT_PROBE_HOSTS,
activeServerOnPort,
detectHyperframesServer,
findPortAndServe,
testPortOnAllHosts,
Expand Down Expand Up @@ -161,6 +163,72 @@ describe("findPortAndServe — bind host (security: F-001)", () => {
});
});

describe("activeServerOnPort — PID provenance (security)", () => {
it("does not signal a server whose PID the OS could not confirm", async () => {
// Fail closed: the only evidence for a blind port-range sweep is an
// unauthenticated config response, so an unconfirmed PID is skipped and
// reported rather than signalled.
const alive = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
stdio: "ignore",
});
const port = await startConfigProbeServer({
isHyperframes: true,
projectName: "demo-project",
projectDir: "/tmp/demo-project",
serverBuildSignature: null,
version: "0.6.42",
pid: alive.pid,
});

const server = await activeServerOnPort(port, async () => null);
expect(server?.pidSource).toBe("self-reported");

// The victim survives, because nothing verified it owns the socket.
expect(alive.killed).toBe(false);
alive.kill();
});

it("tags a self-reported PID and refuses to signal it", async () => {
// The branch with the security consequence. `getProcessOnPort` returns null
// for more than "unsupported platform" — lsof absent, timed out, or unable
// to see another user's socket — and on those machines every scanned port
// used to fall back to the self-report with nothing said.
const port = await startConfigProbeServer({
isHyperframes: true,
projectName: "demo-project",
projectDir: "/tmp/demo-project",
serverBuildSignature: null,
version: "0.6.42",
pid: 999_999,
});

const server = await activeServerOnPort(port, async () => null);

expect(server?.pid).toBe("999999");
expect(server?.pidSource).toBe("self-reported");
});

it("reports the PID that owns the socket, not the one the response claims", async () => {
// `/__hyperframes_config` is unauthenticated and `--stop` / `--kill-all`
// send signals to this field. Trusting the response let any local process
// on a scanned port name an arbitrary PID and have the CLI kill it.
if (process.platform === "win32") return;
const port = await startConfigProbeServer({
isHyperframes: true,
projectName: "demo-project",
projectDir: "/tmp/demo-project",
serverBuildSignature: null,
version: "0.6.42",
pid: 999_999,
});

const server = await activeServerOnPort(port);

expect(server?.pid).toBe(String(process.pid));
expect(server?.pidSource).toBe("os");
});
});

describe("detectHyperframesServer", () => {
it("treats same-project servers with a different server build signature as mismatch", async () => {
const projectDir = "/tmp/demo-project";
Expand Down
146 changes: 107 additions & 39 deletions packages/cli/src/server/portUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import http from "node:http";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolve } from "node:path";
import { c } from "../ui/colors.js";
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";

const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -198,8 +197,13 @@ export function detectHyperframesServer(
* Get the PID of the process listening on a port (macOS/Linux only).
* Returns null on Windows or if detection fails.
*/
/**
* The PID the OS says is listening on `port`, or null when it cannot be
* determined. This is the only trustworthy answer: a config response is
* whatever the process on the other end chose to say.
*/
async function getProcessOnPort(port: number): Promise<string | null> {
if (process.platform === "win32") return null;
if (process.platform === "win32") return windowsListenerPid(port);
try {
const { stdout } = await execFileAsync("lsof", [`-ti:${port}`, "-sTCP:LISTEN"], {
timeout: 2000,
Expand All @@ -211,6 +215,23 @@ async function getProcessOnPort(port: number): Promise<string | null> {
}
}

async function windowsListenerPid(port: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { timeout: 4000 });
for (const line of stdout.split(/\r?\n/)) {
const columns = line.trim().split(/\s+/);
if (columns.length < 5 || columns[3] !== "LISTENING") continue;
const local = columns[1] ?? "";
if (local.slice(local.lastIndexOf(":") + 1) !== String(port)) continue;
const pid = columns[4] ?? "";
return /^\d+$/.test(pid) && pid !== "0" ? pid : null;
}
return null;
} catch {
return null;
}
}

// ── Server discovery ───────────────────────────────────────────────────────

export interface ActiveServer {
Expand All @@ -226,6 +247,13 @@ export interface ActiveServer {
projectDir: string;
version: string;
pid: string | null;
/**
* Where `pid` came from. `"os"` is the kernel's answer for who holds the
* listening socket; `"self-reported"` is whatever the process on the other
* end chose to put in its config response. Callers that SIGNAL the pid must
* require `"os"` — see `killActiveServers`.
*/
pidSource?: "os" | "self-reported";
browserGpuMode?: BrowserGpuMode;
}

Expand Down Expand Up @@ -285,24 +313,7 @@ export async function scanActiveServers(startPort = 3002): Promise<ActiveServer[
const batchEnd = Math.min(batchStart + batchSize - 1, endPort);
const ports = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i);

const results = await Promise.all(
ports.map(async (port) => {
const config = await probePort(port);
if (!config) return null;
const pid =
Number.isInteger(config.pid) && Number(config.pid) > 0
? String(config.pid)
: await getProcessOnPort(port);
return {
port,
projectName: config.projectName,
projectDir: config.projectDir,
version: config.version,
pid,
browserGpuMode: config.browserGpuMode,
};
}),
);
const results = await Promise.all(ports.map((port) => activeServerOnPort(port)));

for (const r of results) {
if (r) servers.push(r);
Expand All @@ -313,25 +324,90 @@ export async function scanActiveServers(startPort = 3002): Promise<ActiveServer[
}

/**
* Kill all active HyperFrames preview servers by sending SIGTERM to their PIDs.
* Returns the number of servers killed.
* Probe exactly one port and return its HyperFrames identity.
*
* `pid` is the OS's answer for who holds the listening socket, NOT the pid the
* response claims. That field is load-bearing — `--stop` and `--kill-all` send
* signals to it — and `/__hyperframes_config` is unauthenticated, so any local
* process that answers on a scanned port could otherwise name an arbitrary PID
* and have the CLI kill it. The self-reported value is used only where the OS
* lookup is unavailable, which is also the only case where it is unfalsifiable.
*/
export async function activeServerOnPort(
port: number,
listenerLookup: (port: number) => Promise<string | null> = getProcessOnPort,
): Promise<ActiveServer | null> {
const config = await probePort(port);
if (!config) return null;
const listenerPid = await listenerLookup(port);
if (listenerPid) return { ...identityFrom(config, port), pid: listenerPid, pidSource: "os" };

// The OS lookup came back empty. That is NOT only "unsupported platform":
// `lsof` may be absent (common on slim images), may time out, or may not see
// a socket owned by another user. The value is still reported, because
// `--list` and the ownership record both have honest uses for it, but it is
// tagged so the paths that send signals can refuse it.
const selfReported =
Number.isInteger(config.pid) && Number(config.pid) > 0 ? String(config.pid) : null;
return {
...identityFrom(config, port),
pid: selfReported,
...(selfReported ? { pidSource: "self-reported" as const } : {}),
};
}

function identityFrom(
config: HyperframesConfigResponse,
port: number,
): Omit<ActiveServer, "pid" | "pidSource"> {
return {
port,
projectName: config.projectName,
projectDir: config.projectDir,
version: config.version,
browserGpuMode: config.browserGpuMode,
};
}

/**
* SIGTERM every active HyperFrames preview server whose PID the OS confirmed.
*
* This is a blind sweep of a port range: the only evidence that a given process
* should be killed is that it answered `/__hyperframes_config`, which is
* unauthenticated. So the decision here is deliberately FAIL CLOSED — a PID the
* OS could not confirm is skipped rather than signalled, because the alternative
* is letting any local process nominate a victim.
*
* The cost is real and bounded: where `lsof` is missing, `--kill-all` stops
* reaping unmanaged servers. Managed previews are unaffected — they stop through
* their session record, which proves ownership by process birth identity rather
* than by asking the port who it is.
*
* Skipped ports are returned so the caller can say so; a security control that
* degrades silently is one nobody knows to fix.
*/
export async function killActiveServers(startPort = 3002): Promise<number> {
export async function killActiveServers(
startPort = 3002,
): Promise<{ killed: number; unverified: number[] }> {
const servers = await scanActiveServers(startPort);
let killed = 0;
const unverified: number[] = [];

for (const server of servers) {
if (server.pid) {
try {
process.kill(parseInt(server.pid, 10), "SIGTERM");
killed++;
} catch {
// Process may have already exited
}
if (!server.pid) continue;
if (server.pidSource !== "os") {
unverified.push(server.port);
continue;
}
try {
process.kill(parseInt(server.pid, 10), "SIGTERM");
killed++;
} catch {
// Process may have already exited
}
}

return killed;
return { killed, unverified };
}

// ── Smart port selection ───────────────────────────────────────────────────
Expand Down Expand Up @@ -416,17 +492,9 @@ export async function findPortAndServe(
return { type: "already-running", port };
}
if (detection.type === "mismatch") {
console.log(
` ${c.dim(`Port ${port} in use by HyperFrames project "${detection.projectName}" — skipping`)}`,
);
continue;
}
}

const pid = await getProcessOnPort(port);
if (pid) {
console.log(` ${c.dim(`Port ${port} in use by PID ${pid} — skipping`)}`);
}
}

throw new Error(
Expand Down
Loading
Loading