From 469d7d908109ac60283b6a742ee538a90fc55822 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Fri, 6 Nov 2026 12:58:13 +0000 Subject: [PATCH 1/2] feat(scripts): stop every Builder process with bun run dev:stop Add scripts/dev-stop.ts, which stops the hubs, migrations, UI dev servers and the sidecars, live-app hosts and build tools they spawned, from this checkout or any other checkout of the same package, and names whatever else holds a port bun run dev needs, such as another project's dev server or the Compose stack. Other projects' processes are left alone. Document it in CONTRIBUTING.md and the DEV.md scripts table. --- CONTRIBUTING.md | 3 + docs/DEV.md | 35 +-- package.json | 1 + scripts/dev-stop.ts | 445 ++++++++++++++++++++++++++++++ test/integration/dev-stop.test.ts | 223 +++++++++++++++ 5 files changed, 690 insertions(+), 17 deletions(-) create mode 100644 scripts/dev-stop.ts create mode 100644 test/integration/dev-stop.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc67575d..4f9ac820 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,9 @@ one [model provider](https://github.com/corbitsdev/corbits-builder/blob/main/doc changing `apps/hub`, `workflows/` or `packages/`. If a port is taken, set `PORT` or `DB_PORT` in `.env.hub` (and `UI_HUB_ORIGIN` in `.env.web` to match `PORT`), or `UI_PORT` in `.env.web`. + `bun run dev:stop` stops the whole stack, including anything an earlier + run left behind, and names whatever else holds a port `bun run dev` needs, + such as another project's dev server or the Compose stack. 5. Sign in. Open http://localhost:5173 and choose **Create an account** with any email and password. Locally there is no email to verify. diff --git a/docs/DEV.md b/docs/DEV.md index 21afd734..4d410784 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -78,23 +78,24 @@ real API ([CONTRIBUTING.md](https://github.com/corbitsdev/corbits-builder/blob/m ## Other scripts -| Script | What it does | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `bun run start` | Starts the hub alone, without the migrations or the UI dev server. | -| `bun run dev:hub` | Starts the hub alone for the UI dev server on `UI_PORT` (from the shell; `bun run dev` also reads it from `.env.web`). | -| `bun run dev:web` | Starts the UI dev server alone. | -| `bun run format` | Formats the tree with bun fmt. | -| `bun run lint` | oxlint, warnings are errors. | -| `bun run typecheck` | Typechecks the hub, the client API and the UI. | -| `bun run db:migrate` | Applies the migrations. | -| `bun run db:generate` | Generates a migration from `apps/hub/src/db/schema.ts`. | -| `bun run docker:up` | Builds and starts Builder in Docker ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | -| `bun run docker:sidecar` | Builds the sidecar Docker image. | -| `bun run auth:bootstrap` | Migrates and creates the `BOOTSTRAP_*` machine account and workspace. | -| `bun run auth:emulate` | Starts a loopback Google sign-in emulator (`GOOGLE_EMULATE_URL` in `.env.hub.example`). | -| `bun run dev:seed` | Seeds dummy projects (`habit-tracker`, `field-service-dispatch`, `clinical-trial-intake`) at a chosen lifecycle stage. | -| `bun run vendor:verify` | Checks `vendor/interchange` matches its pinned upstream commit. | -| `bun run skills:react-ui` | Regenerates the designer's `@corbits/react-ui` API reference. | +| Script | What it does | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bun run start` | Starts the hub alone, without the migrations or the UI dev server. | +| `bun run dev:hub` | Starts the hub alone for the UI dev server on `UI_PORT` (from the shell; `bun run dev` also reads it from `.env.web`). | +| `bun run dev:web` | Starts the UI dev server alone. | +| `bun run dev:stop` | Stops every Builder process on this machine (hubs, migrations, UI dev servers, what they spawned) and names whatever else holds a port `bun run dev` needs; `--dry-run` only lists them. | +| `bun run format` | Formats the tree with bun fmt. | +| `bun run lint` | oxlint, warnings are errors. | +| `bun run typecheck` | Typechecks the hub, the client API and the UI. | +| `bun run db:migrate` | Applies the migrations. | +| `bun run db:generate` | Generates a migration from `apps/hub/src/db/schema.ts`. | +| `bun run docker:up` | Builds and starts Builder in Docker ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | +| `bun run docker:sidecar` | Builds the sidecar Docker image. | +| `bun run auth:bootstrap` | Migrates and creates the `BOOTSTRAP_*` machine account and workspace. | +| `bun run auth:emulate` | Starts a loopback Google sign-in emulator (`GOOGLE_EMULATE_URL` in `.env.hub.example`). | +| `bun run dev:seed` | Seeds dummy projects (`habit-tracker`, `field-service-dispatch`, `clinical-trial-intake`) at a chosen lifecycle stage. | +| `bun run vendor:verify` | Checks `vendor/interchange` matches its pinned upstream commit. | +| `bun run skills:react-ui` | Regenerates the designer's `@corbits/react-ui` API reference. | ## Migrations diff --git a/package.json b/package.json index 2f037ac7..c971c109 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev": "bun run db:migrate && bun --env-file=.env.web run --parallel dev:hub dev:web", "dev:hub": "UI_PUBLIC_URL=http://localhost:${UI_PORT:-5173} bun run start", "dev:web": "bun --env-file=.env.web run --cwd apps/web dev", + "dev:stop": "bun scripts/dev-stop.ts", "start": "exec bun --env-file=.env.hub --conditions=intx-src apps/hub/src/server.ts", "db:migrate": "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/migrate.ts", "db:generate": "bun --conditions=intx-src ./node_modules/drizzle-kit/bin.cjs generate --config=apps/hub/drizzle.config.ts", diff --git a/scripts/dev-stop.ts b/scripts/dev-stop.ts new file mode 100644 index 00000000..f15a7610 --- /dev/null +++ b/scripts/dev-stop.ts @@ -0,0 +1,445 @@ +import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; + +// Stops every process of this app on this machine: hubs, migrations, UI dev +// servers and the sidecars, live-app hosts and build tools they spawned, from +// this checkout or any other checkout of the same package. Other projects' +// `bun run dev` processes are left alone, as is a Docker Compose stack; when +// one of those holds a port `bun run dev` needs, it is named instead. + +export const APP_NAME = "corbits-builder"; + +export type ProcessInfo = { + pid: number; + ppid: number; + command: string; + /** Working directory, when known. */ + cwd?: string | undefined; +}; + +export type Selection = { + /** The pid of this script; it and its ancestors are never selected. */ + self: number; + /** Maps a directory to the checkout root of this app containing it, if any. */ + checkoutOf: (directory: string) => string | undefined; +}; + +const entryPatterns = [ + // The hub (`bun run start` execs it) and the migration runner. + /(^|\s)apps\/hub\/src\/(server|migrate)\.ts(\s|$)/, + // The package.json wrappers: `bun run dev`, `bun run --parallel dev:hub dev:web`, `bun run start`. + /(^|\s)run\s+(--\S+\s+)*(dev|dev:hub|dev:web|start)(\s|$)/, + // The UI dev server. + /(^|\/)vite(\s|$)/, +]; + +const checkoutSubpaths = [ + "apps", + "vendor", + "node_modules", + "packages", + "workflows", +]; + +function executableOf(command: string) { + return command.split(" ")[0] ?? ""; +} + +/** A JavaScript runtime or a tool installed in a checkout, never an editor or a terminal that merely names the checkout. */ +function isAppExecutable(executable: string) { + const name = basename(executable); + return ( + name === "bun" || name === "node" || executable.includes("/node_modules/") + ); +} + +function runsFileOfCheckout(command: string, root: string) { + return checkoutSubpaths.some((subpath) => + command.includes(`${root}/${subpath}/`), + ); +} + +function ancestorsOf(pid: number, byPid: Map) { + const ancestors = new Set(); + for ( + let current = byPid.get(pid); + current && !ancestors.has(current.pid); + current = byPid.get(current.ppid) + ) { + ancestors.add(current.pid); + } + return ancestors; +} + +/** The processes to stop: every process launched from a checkout of this app, plus everything they spawned. */ +export function selectAppProcesses( + processes: ProcessInfo[], + selection: Selection, +): ProcessInfo[] { + const byPid = new Map(processes.map((info) => [info.pid, info])); + const excluded = ancestorsOf(selection.self, byPid); + const roots = new Set(); + for (const info of processes) { + const root = + info.cwd === undefined ? undefined : selection.checkoutOf(info.cwd); + if (root !== undefined) roots.add(root); + } + const selected = new Set(); + for (const info of processes) { + if (excluded.has(info.pid) || !isAppExecutable(executableOf(info.command))) + continue; + const cwdRoot = + info.cwd === undefined ? undefined : selection.checkoutOf(info.cwd); + const byEntry = + cwdRoot !== undefined && + entryPatterns.some((pattern) => pattern.test(info.command)); + const byPath = [...roots].some((root) => + runsFileOfCheckout(info.command, root), + ); + if (byEntry || byPath) selected.add(info.pid); + } + // Children inherit the selection: sidecars, workflow children, esbuild services, sandboxed builds. + let grew = true; + while (grew) { + grew = false; + for (const info of processes) { + if ( + selected.has(info.pid) || + excluded.has(info.pid) || + !selected.has(info.ppid) + ) + continue; + selected.add(info.pid); + grew = true; + } + } + return processes.filter((info) => selected.has(info.pid)); +} + +/** A port `bun run dev` listens on and the variable that sets it. */ +export type NeededPort = { + variable: "PORT" | "UI_PORT"; + port: number; + /** What listens there. */ + role: string; +}; + +export type Listener = { port: number; pid: number }; + +type Env = Record; + +/** The `KEY=value` lines of a dotenv file, without the quoting and expansion Bun applies; enough for a port. */ +export function parseDotenv(text: string): Record { + const values: Record = {}; + for (const line of text.split("\n")) { + const match = + /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (!match) continue; + const [, key, raw] = match; + const value = raw ?? ""; + values[key ?? ""] = /^(["']).*\1$/.test(value) + ? value.slice(1, -1) + : (value.split(" #")[0]?.trim() ?? ""); + } + return values; +} + +function readDotenv(path: string) { + try { + return parseDotenv(readFileSync(path, "utf8")); + } catch { + return {}; + } +} + +function portOf(...candidates: (string | undefined)[]) { + for (const candidate of candidates) { + if (candidate === undefined) continue; + const port = Number(candidate); + if (Number.isInteger(port) && port > 0 && port < 65536) return port; + } + return undefined; +} + +/** + * The ports `bun run dev` needs, resolved as it resolves them: the shell wins + * over the checkout's env files, which win over the defaults. + */ +export function neededPorts( + shell: Env, + hubEnv: Env, + webEnv: Env, +): NeededPort[] { + return [ + { + variable: "PORT", + port: portOf(shell["PORT"], hubEnv["PORT"]) ?? 8000, + role: "the hub", + }, + { + variable: "UI_PORT", + port: portOf(shell["UI_PORT"], webEnv["UI_PORT"]) ?? 5173, + role: "the UI dev server", + }, + ]; +} + +/** Parses `lsof -Fpn` output: a `p` line followed by `n
` lines. */ +export function parseLsofListeners(output: string): Listener[] { + const listeners: Listener[] = []; + let pid: number | undefined; + for (const line of output.split("\n")) { + if (line.startsWith("p")) pid = Number(line.slice(1)); + else if (line.startsWith("n") && pid !== undefined) { + const port = Number(line.slice(line.lastIndexOf(":") + 1)); + if (Number.isInteger(port)) listeners.push({ port, pid }); + } + } + return listeners; +} + +/** Parses `ss -Hltnp` output: one socket per line, the pid in `users:(("name",pid=N,fd=M))`. */ +export function parseSsListeners(output: string): Listener[] { + const listeners: Listener[] = []; + for (const line of output.split("\n")) { + const columns = line.trim().split(/\s+/); + const local = columns[3] ?? ""; + const port = Number(local.slice(local.lastIndexOf(":") + 1)); + for (const match of line.matchAll(/pid=(\d+)/g)) + if (Number.isInteger(port)) + listeners.push({ port, pid: Number(match[1]) }); + } + return listeners; +} + +function listenersOn(ports: number[]): Listener[] { + if (ports.length === 0) return []; + if (existsSync("/proc/self")) { + return parseSsListeners(run(["ss", "-Hltnp"])).filter((listener) => + ports.includes(listener.port), + ); + } + return parseLsofListeners( + run([ + "lsof", + "-nP", + ...ports.map((port) => `-iTCP:${port}`), + "-sTCP:LISTEN", + "-Fpn", + ]), + ); +} + +/** A port published by a container reaches the host through a forwarder, not the container itself. */ +function isContainerForwarder(command: string) { + return /docker|colima|lima|vpnkit|podman|orbstack/i.test(command); +} + +/** Lines naming what holds each needed port, other than the given pids. */ +export function describeHeldPorts( + needed: NeededPort[], + listeners: Listener[], + commandOf: (pid: number) => string | undefined, + ignore: Set = new Set(), +): string[] { + const lines: string[] = []; + for (const { variable, port, role } of needed) { + const holders = new Map(); + for (const listener of listeners) + if (listener.port === port && !ignore.has(listener.pid)) + holders.set(listener.pid, commandOf(listener.pid) ?? "(unknown)"); + for (const [pid, command] of holders) { + const hint = isContainerForwarder(command) + ? " (a container's published port; see `docker ps`)" + : ""; + lines.push( + ` ${String(port).padStart(6)} ${variable} for ${role}: held by pid ${pid} ${command}${hint}`, + ); + } + } + return lines; +} + +function run(command: string[]) { + const result = Bun.spawnSync(command, { stdout: "pipe", stderr: "pipe" }); + return result.stdout.toString(); +} + +function listProcesses(): ProcessInfo[] { + const processes: ProcessInfo[] = []; + for (const line of run(["ps", "-axo", "pid=,ppid=,command="]).split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (match) + processes.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + command: (match[3] ?? "").trimEnd(), + }); + } + return processes; +} + +/** Working directories of the given pids, from /proc where it exists and from lsof elsewhere. */ +function workingDirectories(pids: number[]) { + const cwds = new Map(); + if (pids.length === 0) return cwds; + if (existsSync("/proc/self")) { + for (const pid of pids) { + try { + cwds.set(pid, readlinkSync(`/proc/${pid}/cwd`)); + } catch { + // The process is gone or belongs to someone else; it is not ours to stop. + } + } + return cwds; + } + let pid: number | undefined; + for (const line of run([ + "lsof", + "-a", + "-d", + "cwd", + "-p", + pids.join(","), + "-Fpn", + ]).split("\n")) { + if (line.startsWith("p")) pid = Number(line.slice(1)); + else if (line.startsWith("n") && pid !== undefined) + cwds.set(pid, line.slice(1)); + } + return cwds; +} + +function packageName(directory: string) { + try { + const parsed: unknown = JSON.parse( + readFileSync(resolve(directory, "package.json"), "utf8"), + ); + return typeof parsed === "object" && parsed !== null && "name" in parsed + ? parsed.name + : undefined; + } catch { + return undefined; + } +} + +/** Finds the nearest enclosing checkout of this app, remembering the answer per directory. */ +export function createCheckoutResolver(appName = APP_NAME) { + const known = new Map(); + const resolveRoot = (directory: string): string | undefined => { + const cached = known.get(directory); + if (cached !== undefined || known.has(directory)) return cached; + const parent = dirname(directory); + const root = + packageName(directory) === appName + ? directory + : parent === directory + ? undefined + : resolveRoot(parent); + known.set(directory, root); + return root; + }; + return (directory: string) => { + try { + return resolveRoot(realpathSync(directory)); + } catch { + return undefined; + } + }; +} + +function isAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function signal(pid: number, name: NodeJS.Signals) { + try { + process.kill(pid, name); + } catch { + // Already gone. + } +} + +const describe = (info: ProcessInfo) => + ` ${String(info.pid).padStart(6)} ${info.command.length > 110 ? `${info.command.slice(0, 107)}...` : info.command}`; + +async function main() { + const dryRun = process.argv.includes("--dry-run"); + const gracePeriod = 10_000; + const checkoutOf = createCheckoutResolver(); + const scan = () => { + const processes = listProcesses(); + const candidates = processes.filter((info) => + isAppExecutable(executableOf(info.command)), + ); + const cwds = workingDirectories(candidates.map((info) => info.pid)); + for (const candidate of candidates) candidate.cwd = cwds.get(candidate.pid); + return selectAppProcesses(processes, { self: process.pid, checkoutOf }); + }; + const checkout = resolve(import.meta.dir, ".."); + const needed = neededPorts( + process.env, + readDotenv(resolve(checkout, ".env.hub")), + readDotenv(resolve(checkout, ".env.web")), + ); + const reportHeldPorts = (ignore?: Set) => { + const byPid = new Map(listProcesses().map((info) => [info.pid, info])); + const lines = describeHeldPorts( + needed, + listenersOn(needed.map(({ port }) => port)), + (pid) => byPid.get(pid)?.command, + ignore, + ); + if (lines.length === 0) return; + console.log( + `Ports \`bun run dev\` needs are held by something this script does not stop:`, + ); + for (const line of lines) console.log(line); + console.log( + "Stop it, or set PORT in .env.hub (and UI_HUB_ORIGIN in .env.web) or UI_PORT in .env.web.", + ); + }; + const selected = scan(); + if (selected.length === 0) { + console.log(`Nothing from ${APP_NAME} is running.`); + reportHeldPorts(); + return; + } + console.log( + `${dryRun ? "Would stop" : "Stopping"} ${selected.length} ${APP_NAME} process${selected.length === 1 ? "" : "es"}:`, + ); + for (const info of selected) console.log(describe(info)); + if (dryRun) { + reportHeldPorts(new Set(selected.map((info) => info.pid))); + return; + } + // Parents get the signal first and shut their own children down; the rest is swept afterwards. + const pids = new Set(selected.map((info) => info.pid)); + for (const info of selected) + if (!pids.has(info.ppid)) signal(info.pid, "SIGTERM"); + const deadline = Date.now() + gracePeriod; + while (Date.now() < deadline && [...pids].some(isAlive)) await Bun.sleep(200); + const remaining = [ + ...scan().map((info) => info.pid), + ...[...pids].filter(isAlive), + ]; + for (const pid of new Set(remaining)) signal(pid, "SIGKILL"); + await Bun.sleep(200); + const survivors = [...new Set(remaining)].filter(isAlive); + if (survivors.length > 0) { + console.error(`Could not stop: ${survivors.join(", ")}`); + process.exitCode = 1; + return; + } + console.log( + `Stopped ${selected.length} process${selected.length === 1 ? "" : "es"}.`, + ); + reportHeldPorts(); +} + +if (import.meta.main) await main(); diff --git a/test/integration/dev-stop.test.ts b/test/integration/dev-stop.test.ts new file mode 100644 index 00000000..02f79e6e --- /dev/null +++ b/test/integration/dev-stop.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; +import { + describeHeldPorts, + neededPorts, + parseDotenv, + parseLsofListeners, + parseSsListeners, + type ProcessInfo, + selectAppProcesses, +} from "../../scripts/dev-stop.ts"; + +// A machine running this app next to other projects' `bun run dev`, a terminal +// that names the checkout, and the shell running `bun run dev:stop` itself. +const here = "/home/dev/corbits-builder"; +const elsewhere = "/home/dev/corbits-builder-worktree"; +const other = "/home/dev/other-app"; +const checkoutOf = (directory: string) => + [here, elsewhere].find( + (root) => directory === root || directory.startsWith(`${root}/`), + ); + +const bun = ( + pid: number, + ppid: number, + command: string, + cwd?: string, +): ProcessInfo => ({ pid, ppid, command, cwd }); +const processes: ProcessInfo[] = [ + // Another project's dev stack, with a hub-like entry point of its own. + bun(100, 1, "bun run dev", other), + bun(101, 100, "bun scripts/dev.ts", other), + bun(102, 101, `bun ${other}/apps/hub/src/server.ts --open`, other), + bun(103, 101, `node ${other}/node_modules/.bin/vite --port 5173`, other), + // A terminal helper and an editor that merely name the checkout. + { + pid: 200, + ppid: 1, + command: `/Applications/iTerm.app/Contents/MacOS/pidinfo --git-state ${here} 4 1`, + }, + { + pid: 201, + ppid: 1, + command: `/usr/local/bin/code ${here}/apps/hub/src/server.ts`, + }, + // This app, started by `bun run dev` in this checkout. + bun(300, 250, "bun run dev", here), + bun(301, 300, "bun --env-file=.env.web run --parallel dev:hub dev:web", here), + bun(302, 301, "bun run dev:hub", here), + bun( + 303, + 302, + "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/server.ts", + here, + ), + bun( + 304, + 303, + `bun ${here}/vendor/interchange/apps/sidecar/src/index.ts`, + here, + ), + bun( + 305, + 304, + `bun ${here}/vendor/interchange/apps/sidecar/bin/workflow-child`, + here, + ), + bun(306, 301, "bun run dev:web", here), + bun( + 307, + 306, + `node ${here}/apps/web/node_modules/.bin/vite`, + `${here}/apps/web`, + ), + bun( + 308, + 307, + `${here}/node_modules/.bun/@esbuild+darwin-arm64@0.28.2/node_modules/@esbuild/darwin-arm64/bin/esbuild --service=0.28.2 --ping`, + `${here}/apps/web`, + ), + // A live-app host and the project app under it, running from the data directory. + bun( + 309, + 303, + `bun ${here}/apps/hub/src/live-app/host.ts ${here}/data/hub/live-apps/p1/workspace`, + here, + ), + { + pid: 310, + ppid: 309, + command: "npm run dev", + cwd: `${here}/data/hub/live-apps/p1/workspace`, + }, + // A sidecar orphaned by an earlier hub, and a hub from another checkout started with `bun run start`. + bun(400, 1, `bun ${here}/vendor/interchange/apps/sidecar/src/index.ts`, here), + bun( + 401, + 1, + "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/server.ts", + elsewhere, + ), + bun( + 402, + 1, + "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/migrate.ts", + elsewhere, + ), + // The shell running `bun run dev:stop` and its ancestors. + { pid: 250, ppid: 1, command: "-bash", cwd: here }, + bun(500, 250, "bun run dev:stop", here), + bun(501, 500, "bun scripts/dev-stop.ts", here), + // Unrelated work in this checkout. + bun(600, 250, "bun --conditions=intx-src test ./apps/hub", here), + bun(601, 250, "bun run dev:seed", here), +]; + +describe("dev:stop", () => { + test("selects this app's processes from every checkout and nothing else", () => { + const selected = selectAppProcesses(processes, { + self: 501, + checkoutOf, + }).map((info) => info.pid); + expect(selected.sort((a, b) => a - b)).toEqual([ + 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 400, 401, 402, + ]); + }); + + test("finds nothing when the app is not running", () => { + const idle = processes.filter((info) => info.pid < 300 || info.pid >= 500); + expect(selectAppProcesses(idle, { self: 501, checkoutOf })).toEqual([]); + }); +}); + +describe("dev:stop port report", () => { + test("resolves the ports as bun run dev does: shell, then env files, then defaults", () => { + expect(neededPorts({}, {}, {}).map(({ port }) => port)).toEqual([ + 8000, 5173, + ]); + expect( + neededPorts({}, { PORT: "8877" }, { UI_PORT: "5174" }).map( + ({ port }) => port, + ), + ).toEqual([8877, 5174]); + expect( + neededPorts( + { PORT: "9000", UI_PORT: "5175" }, + { PORT: "8877" }, + { UI_PORT: "5174" }, + ).map(({ port }) => port), + ).toEqual([9000, 5175]); + expect( + neededPorts({ PORT: "later" }, { PORT: "0" }, {}).map(({ port }) => port), + ).toEqual([8000, 5173]); + }); + + test("reads a port from a dotenv file with comments and quotes", () => { + expect( + parseDotenv( + [ + "# The hub", + "PORT=8877 # published by compose", + 'export UI_PORT="5174"', + "DB_URL='postgres://x:y@h/db'", + "not a line", + ].join("\n"), + ), + ).toEqual({ + PORT: "8877", + UI_PORT: "5174", + DB_URL: "postgres://x:y@h/db", + }); + }); + + test("parses listeners from lsof and ss", () => { + expect( + parseLsofListeners( + "p41465\ncnode\nf18\nn[::1]:5173\np94796\ncssh\nf34\nn127.0.0.1:8877\n", + ), + ).toEqual([ + { port: 5173, pid: 41465 }, + { port: 8877, pid: 94796 }, + ]); + expect( + parseSsListeners( + [ + 'LISTEN 0 511 [::1]:5173 [::]:* users:(("node",pid=41465,fd=18))', + 'LISTEN 0 4096 127.0.0.1:8877 0.0.0.0:* users:(("docker-proxy",pid=94796,fd=4))', + "", + ].join("\n"), + ), + ).toEqual([ + { port: 5173, pid: 41465 }, + { port: 8877, pid: 94796 }, + ]); + }); + + test("names what holds each needed port, skipping what is being stopped", () => { + const needed = neededPorts({}, { PORT: "8877" }, {}); + const listeners = [ + { port: 5173, pid: 41465 }, + { port: 8877, pid: 94796 }, + { port: 8877, pid: 303 }, + { port: 9999, pid: 1 }, + ]; + const commands = new Map([ + [41465, `node ${other}/node_modules/.bin/vite --port 5173`], + [94796, "ssh: /home/dev/.colima/_lima/colima/ssh.sock [mux]"], + [303, "bun apps/hub/src/server.ts"], + ]); + const lines = describeHeldPorts( + needed, + listeners, + (pid) => commands.get(pid), + new Set([303]), + ); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("8877"); + expect(lines[0]).toContain("pid 94796"); + expect(lines[0]).toContain("docker ps"); + expect(lines[1]).toContain("5173"); + expect(lines[1]).toContain("vite --port 5173"); + expect(lines[1]).not.toContain("docker ps"); + }); +}); From 4e4c371838ae14dc3db5080480590127bbbaca47 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Fri, 6 Nov 2026 13:45:48 +0000 Subject: [PATCH 2/2] refactor(scripts): name every caught error in dev:stop and drop invented fallbacks Each catch checks for the failure it expects, a missing file or a gone process, and rethrows the rest; a missing field is skipped instead of read as an empty string; the checkout resolver uses if statements. The test names the sidecar entry points once, outside any call. --- scripts/dev-stop.ts | 65 ++++++++++++++++++++----------- test/integration/dev-stop.test.ts | 19 +++------ 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/scripts/dev-stop.ts b/scripts/dev-stop.ts index f15a7610..b40f08b5 100644 --- a/scripts/dev-stop.ts +++ b/scripts/dev-stop.ts @@ -42,9 +42,19 @@ const checkoutSubpaths = [ ]; function executableOf(command: string) { - return command.split(" ")[0] ?? ""; + return command.split(" ", 1).join(""); } +// Whether a system error carries one of the given codes. +function errnoIn(error: unknown, codes: readonly string[]) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code !== undefined && codes.includes(code); +} +// A process that is gone, or that belongs to someone else. +const GONE = ["ESRCH", "ENOENT", "EACCES", "EPERM"]; +// A path that is gone or cannot be reached. +const UNREACHABLE = ["ENOENT", "ENOTDIR", "EACCES", "EPERM"]; + /** A JavaScript runtime or a tool installed in a checkout, never an editor or a terminal that merely names the checkout. */ function isAppExecutable(executable: string) { const name = basename(executable); @@ -135,11 +145,12 @@ export function parseDotenv(text: string): Record { const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); if (!match) continue; - const [, key, raw] = match; - const value = raw ?? ""; - values[key ?? ""] = /^(["']).*\1$/.test(value) - ? value.slice(1, -1) - : (value.split(" #")[0]?.trim() ?? ""); + const key = match[1]; + const raw = match[2]; + if (key === undefined || raw === undefined) continue; + values[key] = /^(["']).*\1$/.test(raw) + ? raw.slice(1, -1) + : raw.split(" #", 1).join("").trim(); } return values; } @@ -147,8 +158,10 @@ export function parseDotenv(text: string): Record { function readDotenv(path: string) { try { return parseDotenv(readFileSync(path, "utf8")); - } catch { - return {}; + } catch (error) { + // No dotenv file means the defaults. + if (errnoIn(error, UNREACHABLE)) return {}; + throw error; } } @@ -203,7 +216,8 @@ export function parseSsListeners(output: string): Listener[] { const listeners: Listener[] = []; for (const line of output.split("\n")) { const columns = line.trim().split(/\s+/); - const local = columns[3] ?? ""; + const local = columns[3]; + if (local === undefined) continue; const port = Number(local.slice(local.lastIndexOf(":") + 1)); for (const match of line.matchAll(/pid=(\d+)/g)) if (Number.isInteger(port)) @@ -269,11 +283,12 @@ function listProcesses(): ProcessInfo[] { const processes: ProcessInfo[] = []; for (const line of run(["ps", "-axo", "pid=,ppid=,command="]).split("\n")) { const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); - if (match) + const command = match?.[3]; + if (match && command !== undefined) processes.push({ pid: Number(match[1]), ppid: Number(match[2]), - command: (match[3] ?? "").trimEnd(), + command: command.trimEnd(), }); } return processes; @@ -287,8 +302,9 @@ function workingDirectories(pids: number[]) { for (const pid of pids) { try { cwds.set(pid, readlinkSync(`/proc/${pid}/cwd`)); - } catch { + } catch (error) { // The process is gone or belongs to someone else; it is not ours to stop. + if (!errnoIn(error, GONE)) throw error; } } return cwds; @@ -318,8 +334,11 @@ function packageName(directory: string) { return typeof parsed === "object" && parsed !== null && "name" in parsed ? parsed.name : undefined; - } catch { - return undefined; + } catch (error) { + // No package.json here, or one that is not JSON: not a checkout root. + if (errnoIn(error, UNREACHABLE) || error instanceof SyntaxError) + return undefined; + throw error; } } @@ -330,20 +349,19 @@ export function createCheckoutResolver(appName = APP_NAME) { const cached = known.get(directory); if (cached !== undefined || known.has(directory)) return cached; const parent = dirname(directory); - const root = - packageName(directory) === appName - ? directory - : parent === directory - ? undefined - : resolveRoot(parent); + let root: string | undefined; + if (packageName(directory) === appName) root = directory; + else if (parent !== directory) root = resolveRoot(parent); known.set(directory, root); return root; }; return (directory: string) => { try { return resolveRoot(realpathSync(directory)); - } catch { - return undefined; + } catch (error) { + // A working directory that is gone is in no checkout. + if (errnoIn(error, UNREACHABLE)) return undefined; + throw error; } }; } @@ -360,8 +378,9 @@ function isAlive(pid: number) { function signal(pid: number, name: NodeJS.Signals) { try { process.kill(pid, name); - } catch { + } catch (error) { // Already gone. + if (!errnoIn(error, GONE)) throw error; } } diff --git a/test/integration/dev-stop.test.ts b/test/integration/dev-stop.test.ts index 02f79e6e..648ba848 100644 --- a/test/integration/dev-stop.test.ts +++ b/test/integration/dev-stop.test.ts @@ -14,6 +14,9 @@ import { const here = "/home/dev/corbits-builder"; const elsewhere = "/home/dev/corbits-builder-worktree"; const other = "/home/dev/other-app"; +// The sidecar entry points a hub starts, named as the commands a real one shows. +const sidecarEntry = `${here}/vendor/interchange/apps/sidecar/src/index.ts`; +const workflowChild = `${here}/vendor/interchange/apps/sidecar/bin/workflow-child`; const checkoutOf = (directory: string) => [here, elsewhere].find( (root) => directory === root || directory.startsWith(`${root}/`), @@ -52,18 +55,8 @@ const processes: ProcessInfo[] = [ "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/server.ts", here, ), - bun( - 304, - 303, - `bun ${here}/vendor/interchange/apps/sidecar/src/index.ts`, - here, - ), - bun( - 305, - 304, - `bun ${here}/vendor/interchange/apps/sidecar/bin/workflow-child`, - here, - ), + bun(304, 303, `bun ${sidecarEntry}`, here), + bun(305, 304, `bun ${workflowChild}`, here), bun(306, 301, "bun run dev:web", here), bun( 307, @@ -91,7 +84,7 @@ const processes: ProcessInfo[] = [ cwd: `${here}/data/hub/live-apps/p1/workspace`, }, // A sidecar orphaned by an earlier hub, and a hub from another checkout started with `bun run start`. - bun(400, 1, `bun ${here}/vendor/interchange/apps/sidecar/src/index.ts`, here), + bun(400, 1, `bun ${sidecarEntry}`, here), bun( 401, 1,