From a2dbc7c6ff3aef04947816502a2b22ae4d0530f9 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Fri, 25 Sep 2026 12:42:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feature/Make-serve-refused-no-ps=20=C2=B7?= =?UTF-8?q?=20L-260923-e82e4a=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where `ps` may not run, as in Codex's `workspace-write` sandbox on macOS, the web app template's `make serve` started a server it could not record and reported it as exited, and `make stop` took a running server for another process and dropped its record, so nothing could stop it. Both now refuse as `refused: no-ps` with the record kept, a running group `lsof` cannot see is refused the same way as `no-lsof`, and any server serve or stop meant to stop and could not is reported as `failed: still-running` with its record kept instead of as stopped. The lifecycle tests skip where `ps` may not run, so the template's `make all` passes in that sandbox. Closes L-260923-e82e4a 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Summary by cubic Fixes `make serve` and `make stop` where `ps` may not run, as in Codex's `workspace-write` sandbox on macOS. Previously `make serve` started a server it couldn't record and reported it as exited, and `make stop` could take a running server for another process, drop its record, and leave it listening with no way to stop it. Both now refuse as `refused: no-ps` without signaling anything, keeping the record. **Bug Fixes** - `make serve` checks it can read a start time before starting anything and refuses with `refused: no-ps` otherwise. - `make stop` refuses as `no-ps` or `no-lsof` while a recorded group still runs but can't be identified, keeping the record; a group that has ended is still cleared. - A server that couldn't be stopped, because the system refused the signal or it outlived `SIGKILL`, is reported `failed: still-running` with its record kept instead of `stopped`. - Lifecycle tests skip where `ps` may not run, so `make all` passes in that sandbox. Closes L-260923-e82e4a Written for commit d9647b909ba56c0c01bf8bac4037fd38a42a6093. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 9 + webapp-js/CLAUDE.md | 2 +- webapp-js/README.md | 2 +- webapp-js/scripts/lib/serve.mts | 384 ++++++++++++++++++++++----- webapp-js/scripts/lib/serve.test.mts | 328 +++++++++++++++++++++-- 5 files changed, 640 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cfb4e..19e54f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Fixed + +- **`make serve` where `ps` may not run**: in a sandbox that refuses `ps`, as Codex's `workspace-write` sandbox does on macOS, the web app template's `make serve` now refuses with `refused: no-ps` before starting anything, where it used to start the server, stop it again and report `failed: exited` as if it had exited at once. +- **`make stop` keeps the record of a server it cannot identify**: while the recorded server's first process runs where `ps` may not, or its group runs where `lsof` sees none of its processes, `make stop` now refuses and keeps `.serve/state.json`, where it used to take the server for another process or for one that had ended, drop its record and report `not-running` while it still listened. The record of a server that has ended is still cleared. +- **`failed: still-running`**: a server that `make serve` or `make stop` meant to stop and could not, because the system refused the signal or it outlived `SIGKILL`, is now reported as `failed: still-running` with its record kept, where it used to be reported as stopped and its record dropped. +- **`make all` where `ps` may not run**: a project's tests now skip the cases that start a server where `ps` may not run, so its `make all` passes in such a sandbox instead of failing. + ## [v0.5.4] - 2026-09-25 ### Changed diff --git a/webapp-js/CLAUDE.md b/webapp-js/CLAUDE.md index a7951a9..7125cf5 100644 --- a/webapp-js/CLAUDE.md +++ b/webapp-js/CLAUDE.md @@ -422,7 +422,7 @@ Other targets that matter: - **The servers listen on loopback by default, and that default is a security property — never drop the `-H`.** The Server Actions run methods with the `PIPELEX_API_KEY` in the server's environment and nothing authenticates the browser calling them, so a server anyone on the network can reach spends the developer's key for them. `next dev` and `next start` given no host bind every interface, which is why both scripts pass `-H ${APP_HOST:-127.0.0.1}`. The host is declared once, as `APP_HOST` in the `Makefile`, beside `APP_PORT` and exported with it; `make dev APP_HOST=0.0.0.0` widens it for a container or another device, and `make run` and `make start` print a warning whenever the host is not loopback. Unlike a gesture's variables, the two are taken from the shell too, so a container can set them in its environment. `scripts/lib/makefile.test.mts` pins the default, the scripts' expansion and the warning. - **The dev server runs on port 4300, and it must not go back to 4100.** The number is declared once, as `APP_PORT` in the `Makefile`, which exports it; `package.json`'s `dev`/`start` scripts and `playwright.config.ts` each read it and each default to 4300 on their own, so `npm run dev` outside make still works. Override it per invocation — `make run APP_PORT=4301` — which is what lets a second checkout run beside one that already holds the port. The variables are deliberately not the ambient `HOST`, `HOSTNAME` or `PORT`: the shell sets `HOSTNAME` to the machine's name, and hosting platforms, other dev servers and shell profiles export the others, so inheriting one would widen or move this server without saying so. 4100 is avoided because the Pipelex server's local stack publishes its build-chatbot sandbox on `127.0.0.1:4100`. On the loopback default that collision is loud: `next dev` fails with `EADDRINUSE`. With `APP_HOST` widened it is silent: Docker holds IPv4 loopback, so `next dev` still binds the wildcard and prints `Ready`, while Playwright's health check on `127.0.0.1` reaches the container's 404 forever and fails with `Timed out waiting 120000ms from config.webServer`. When e2e times out with the app apparently up, run `lsof -nP -iTCP:4300 -sTCP:LISTEN` before believing anything else. - **`make run`, `make start` and `make test-e2e` refuse a port held by another checkout, and that guard is worth keeping.** Several checkouts of the same app all want 4300, so the holder is routinely another checkout — which answers on `http://127.0.0.1:4300` and looks entirely right in a browser. The `port-check` target reads the holder's own working directory (`lsof -a -p -d cwd`) and compares it against `$(CURDIR)`, so the refusal names the directory actually serving the port instead of printing Node's bare `EADDRINUSE`; when the holder is this checkout, it also names the address that server listens on. The e2e targets pass `ALLOW_OWN=1`, which accepts a server started from **this** directory (Playwright's `reuseExistingServer` is meant to reuse it) and still refuses a foreign one. -- **`make serve` is the dev server an agent starts; `make dev` holds the terminal.** `scripts/lib/serve.mts` spawns `npm run dev` detached, in a process group of its own, records the group and its first process's start time in `.serve/state.json` and the server's output in `.serve/server.log` (all of `.serve/` is gitignored), and ends with one verdict line whose first word is stable: `serving` or `already-serving` with the URL and the page's title, `refused: not-loopback | port-held | no-lsof | bad-port | busy`, `failed: not-listening | exited | page | interrupted`, and `stopped` or `not-running` for `make stop`. Read the verdict, not the exit code, and when a refusal comes from make, the last line is make's own `Error` line, so the verdict is the line before it. Its rules, each tested in `scripts/lib/serve.test.mts` against a fake dev server: loopback is checked before the start (the host, and the `dev` script's `-H`) and after it (every listening socket of the group, on any port); the listener is recognised by its process group, so no path is compared for the server serve started; a server a person started here is recognised by its working directory, reported, and never stopped; anything serve started that is not proven, interrupted runs and errors included, is stopped with every process under it; a recorded group is signalled only while its first process, if it still runs, started when the record says and one of its processes runs in this checkout, so a stale record whose id was reused is never signalled; and a serve or a stop holds `.serve/lock` from its first look to its verdict, so a second run waits for the first rather than racing it, while a lock a killed run left behind is refused as `busy`, naming the file, and never broken. It refuses an `lsof` that cannot find its own process, as BusyBox's, which Alpine images ship under the name, cannot, and the cases that start a server are skipped where no usable `lsof` is found, so `make all` stays green in a slim image. Interruptions include SIGHUP, a terminal closing during the start, and an `lsof` or `ps` a signal killed counts as one rather than as an empty answer, so a Ctrl-C landing in one never takes a running server for gone and drops its record. A process's start time is read in UTC, so shells with different time zones agree on it. The port is `APP_PORT` only when the command line or the shell gives it (the Makefile reads its origin), otherwise the first of 4300 to 4309 that no other directory holds. It refuses without `lsof` instead of passing silently as `port-check` does. +- **`make serve` is the dev server an agent starts; `make dev` holds the terminal.** `scripts/lib/serve.mts` spawns `npm run dev` detached, in a process group of its own, records the group and its first process's start time in `.serve/state.json` and the server's output in `.serve/server.log` (all of `.serve/` is gitignored), and ends with one verdict line whose first word is stable: `serving` or `already-serving` with the URL and the page's title, `refused: not-loopback | port-held | no-lsof | no-ps | bad-port | busy`, `failed: not-listening | exited | page | no-ps | interrupted`, `failed: still-running` when a server of its own that it meant to stop still runs, and `stopped` or `not-running` for `make stop`. Read the verdict, not the exit code, and when a refusal comes from make, the last line is make's own `Error` line, so the verdict is the line before it. Its rules, each tested in `scripts/lib/serve.test.mts` against a fake dev server: loopback is checked before the start (the host, and the `dev` script's `-H`) and after it (every listening socket of the group, on any port); the listener is recognised by its process group, so no path is compared for the server serve started; a server a person started here is recognised by its working directory, reported, and never stopped; anything serve started that is not proven, interrupted runs and errors included, is stopped with every process under it, or reported as `failed: still-running` when it outlives the stop, with its record kept if it was recorded; a recorded group is signalled only while its first process, if it still runs, started when the record says and one of its processes runs in this checkout, so a stale record whose id was reused is never signalled; a start time that cannot be read proves neither way, and nor does a running group none of whose processes `lsof` can see, so the group is left alone and its record kept; a record is removed only once its group has ended or is proven another's, never after a stop the system refused, as a sandbox refuses to signal a process started outside it; and a serve or a stop holds `.serve/lock` from its first look to its verdict, so a second run waits for the first rather than racing it, while a lock a killed run left behind is refused as `busy`, naming the file, and never broken. It refuses an `lsof` that cannot find its own process, as BusyBox's, which Alpine images ship under the name, cannot, and the cases that start a server are skipped where no usable `lsof` is found, so `make all` stays green in a slim image. `make serve` refuses as `no-ps`, before anything starts and with any record kept, when it cannot read its own process's start time, which Linux keeps in `/proc` and `ps` reads elsewhere: Codex's `workspace-write` sandbox on macOS will not run `ps`, a setuid program, and a server serve started there could never be told from a process given its id later. `make stop` refuses so only while the recorded group's first process still runs, the one process whose start time is compared. The cases that start a server are skipped there too. Interruptions include SIGHUP, a terminal closing during the start, and an `lsof` or `ps` a signal killed counts as one rather than as an empty answer, so a Ctrl-C landing in one never takes a running server for gone and drops its record. A process's start time is read in UTC, so shells with different time zones agree on it. The port is `APP_PORT` only when the command line or the shell gives it (the Makefile reads its origin), otherwise the first of 4300 to 4309 that no other directory holds. It refuses without `lsof` instead of passing silently as `port-check` does. - **Husky `prepare` warning**: `npm install` prints `.git can't be found` when this directory is not the root of a git repository — before `git init`, for instance. Harmless — just re-run `npm install` after `git init` to wire `.husky/_/`. - **Renaming App Router directories**: delete `.next/` before running `make check` — stale type references in `.next/types/` will fail typecheck. - **`next-env.d.ts` is generated** (gitignored). Next regenerates it on dev/build. Don't edit by hand. diff --git a/webapp-js/README.md b/webapp-js/README.md index 2ac83bd..7063b98 100644 --- a/webapp-js/README.md +++ b/webapp-js/README.md @@ -75,7 +75,7 @@ A variable already exported in your shell wins over `.env.local`. Widen the host only on a network you trust, for a container or to open the app on another device: `make dev APP_HOST=0.0.0.0`. The Makefile prints a warning each time a server starts beyond loopback. `npm run dev` and `npm run start` read the same two variables and fall back to the same defaults. -`make serve` runs the dev server in the background and never beyond loopback: it refuses an `APP_HOST` that is not, and it stops a server that turns out to listen anywhere else before a page can compile. It takes `APP_PORT` when you give one, and otherwise the first port from 4300 to 4309 that no other directory holds. It then checks that the listener is the server it started, requests the page, and ends with one line: `serving http://127.0.0.1:4300/ — ""`, or a refusal or a failure naming its cause. The server's log is `.serve/server.log`. Running it again reports the same server as `already-serving`, and a server you started here with `make dev` is reported too and left alone. Two runs in one checkout take turns, the second waiting for the first. `make stop` stops only what `make serve` started. It needs `lsof`, which macOS ships; on Linux, install it from your distribution's packages if `make serve` says it is missing, BusyBox's included. +`make serve` runs the dev server in the background and never beyond loopback: it refuses an `APP_HOST` that is not, and it stops a server that turns out to listen anywhere else before a page can compile. It takes `APP_PORT` when you give one, and otherwise the first port from 4300 to 4309 that no other directory holds. It then checks that the listener is the server it started, requests the page, and ends with one line: `serving http://127.0.0.1:4300/ — "<title>"`, or a refusal or a failure naming its cause. The server's log is `.serve/server.log`. Running it again reports the same server as `already-serving`, and a server you started here with `make dev` is reported too and left alone. Two runs in one checkout take turns, the second waiting for the first. `make stop` stops only what `make serve` started. It needs `lsof`, which macOS ships; on Linux, install it from your distribution's packages if `make serve` says it is missing, BusyBox's included. Outside Linux it also needs to run `ps`, which a sandbox may refuse, as Codex's does on macOS: `make serve` then says `refused: no-ps` before starting anything, and so does `make stop` while the first process of the server it recorded still runs, leaving that server and its record as they were; both work once run outside the sandbox. ## Make targets diff --git a/webapp-js/scripts/lib/serve.mts b/webapp-js/scripts/lib/serve.mts index bcf9806..2f466ba 100644 --- a/webapp-js/scripts/lib/serve.mts +++ b/webapp-js/scripts/lib/serve.mts @@ -12,10 +12,14 @@ * * serving, already-serving the page answered * refused: not-loopback, refused: port-held, nothing was started - * refused: no-lsof, refused: bad-port, - * refused: busy + * refused: no-lsof, refused: no-ps, + * refused: bad-port, refused: busy * failed: not-listening, failed: exited, what was started is stopped - * failed: page <status>, failed: interrupted + * failed: page <status>, failed: no-ps, + * failed: interrupted + * failed: still-running a server of serve's that it + * meant to stop still runs, and + * stays recorded if it was * stopped, not-running make stop * * These rules make the verdict true rather than hopeful: @@ -38,7 +42,14 @@ * the one serve started only while that first process, if it still runs, * started when the record says, and one of the group's processes runs in * this checkout: a stale record whose id now names a shell, an editor or the - * very `make` running the command is never signalled. + * very `make` running the command is never signalled. A start time that + * cannot be read proves neither way, and nor does a running group none of + * whose processes `lsof` can see, so the record is kept and nothing is + * signalled. + * - **A record goes only with its server.** It is removed once its group has + * ended or is proven another's, never on a stop the system refused, as a + * sandbox refuses to signal a process started outside it: the record is how + * a later `make stop` finds the server. * - **One run at a time.** A serve or a stop holds `.serve/lock` from its first * look at the state to its verdict, so two never undo each other. One that * finds it held waits, and a second `make serve` then reports the server the @@ -49,7 +60,9 @@ * the checkout's real path, in the letter case the disk has. * - **Nothing unproven is left running.** A server whose port does not open in * time, that exits, whose page does not answer `200`, or whose start is - * interrupted or broken by an error, is stopped with every process under it. + * interrupted or broken by an error, is stopped with every process under it, + * and one the system will not let serve stop is reported as still running, + * never as stopped. * * Serve never stops what it did not start: a server a person started from this * checkout is reported and left alone, and a port another directory holds is @@ -57,6 +70,10 @@ * * It needs `lsof`, and refuses without it rather than passing silently as * `port-check` does, since a proof that cannot check the listener is not one. + * It needs a process's start time too, read from `/proc` on Linux and with + * `ps` elsewhere, and refuses before anything starts when it cannot read its + * own, as in a sandbox that will not run `ps`: the server it started could not + * be told from a process given the same id later, so it could not be stopped. * An `lsof` or `ps` killed by a signal has not answered either: the terminal's * Ctrl-C or hangup reaches them as it reaches serve, and their silence read as * "nothing runs" would take a running server for gone and drop its record. So @@ -120,6 +137,8 @@ export interface ServeConfig { graceMs: number; /** The `lsof` to run; a test names one that does not exist. */ lsof: string; + /** The `ps` that reads a start time where `/proc` does not; a test names one that cannot run. */ + ps: string; print: (line: string) => void; } @@ -132,6 +151,7 @@ export const DEFAULT_CONFIG: ServeConfig = { pageMs: 120_000, graceMs: 5_000, lsof: "lsof", + ps: "ps", print: (line) => console.log(line), }; @@ -147,7 +167,31 @@ export interface ServeState { startedAt: string; } -class NoLsofError extends Error {} +/** + * An lsof serve cannot read: missing, or not taking lsof's options, or, with + * `pgid`, one that sees none of a recorded group's processes while the group + * still runs. + */ +class NoLsofError extends Error { + readonly pgid?: number; + constructor(pgid?: number) { + super(pgid === undefined ? "no usable lsof" : `lsof sees no process of group ${pgid}`); + this.pgid = pgid; + } +} + +/** + * A start time serve needs and cannot read: its own process's, when `ps` cannot + * run here at all, or, with `pid`, that of a recorded group's first process, + * which still runs. + */ +class NoPsError extends Error { + readonly pid?: number; + constructor(pid?: number) { + super(pid === undefined ? "no start time" : `no start time for pid ${pid}`); + this.pid = pid; + } +} /** An `lsof` or `ps` serve ran was killed by a signal, so it gave no answer. */ class HelperKilledError extends Error { @@ -328,9 +372,11 @@ function groupAlive(pgid: number): boolean { * `/proc`, in clock ticks since the boot, so the boot's id goes with it, and * this needs no `ps`, which a slim image may lack; elsewhere `ps` reads it, in * a locale and a time zone fixed so that two shells agree on its spelling: it - * prints local time, and an agent's shell often sets `TZ=UTC`. + * prints local time, and an agent's shell often sets `TZ=UTC`. A `ps` that + * cannot run at all throws `NoPsError`, as when a sandbox refuses it: Codex's, + * on macOS, will not run a setuid program, and `ps` is one. */ -export function startTimeOf(pid: number): string | undefined { +export function startTimeOf(pid: number, ps = "ps"): string | undefined { if (process.platform === "linux") { let stat: string; try { @@ -349,33 +395,107 @@ export function startTimeOf(pid: number): string | undefined { } return `${boot}:${fields[19]}`; } - const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { + const result = spawnSync(ps, ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, }); + if (result.error !== undefined) throw new NoPsError(); if (result.signal !== null) throw new HelperKilledError("ps", result.signal); const text = result.status === 0 ? result.stdout.trim() : ""; return text === "" ? undefined : text; } -function signalGroup(pgid: number, signal: NodeJS.Signals): void { +/** + * Whether serve can read a start time here: its own process's, which runs, so + * no answer means none can be read. A sandbox that will not run `ps` fails it. + */ +export function psUsable(ps: string): boolean { + try { + return startTimeOf(process.pid, ps) !== undefined; + } catch (error) { + if (error instanceof NoPsError) return false; + throw error; + } +} + +function requirePs(ps: string): void { + if (!psUsable(ps)) throw new NoPsError(); +} + +/** How a start time is read here, for a verdict to say. */ +function startTimeReader(config: ServeConfig): string { + return process.platform === "linux" ? "from /proc" : `with ${config.ps}`; +} + +/** + * When a recorded group's first process started, or `undefined` once it no + * longer runs, which needs no start time read, so no `ps`. One that still runs + * and whose start time cannot be read throws `NoPsError` rather than + * answering: read as another process's, its record would be dropped while the + * server may still listen, and read as serve's, a process given the same id + * later could be signalled. + */ +function leaderStartOf(pid: number, ps: string): string | undefined { + if (!processAlive(pid)) return undefined; + const start = startTimeOf(pid, ps); + if (start === undefined && processAlive(pid)) throw new NoPsError(pid); + return start; +} + +/** + * Signal a whole group, and say whether the signal reached it: a group already + * gone needs none, and `EPERM` means the system refused it, as a sandbox + * refuses a signal to a process started outside it. + */ +function signalGroup(pgid: number, signal: NodeJS.Signals): boolean { try { process.kill(-pgid, signal); - } catch { - // The group is already gone. + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "EPERM"; } } -/** Stop a whole group: a polite signal, then a hard one once the grace period runs out. */ -export async function stopGroup(pgid: number, graceMs: number): Promise<void> { - signalGroup(pgid, "SIGTERM"); +/** How stopping a group ended: it has, the system refused the signal, or it outlived the hard one. */ +export type StopOutcome = "stopped" | "refused" | "survived"; + +/** + * Stop a whole group: a polite signal, then a hard one once the grace period + * runs out. A refused signal ends the attempt at once, since waiting would not + * change the answer. + */ +export async function stopGroup(pgid: number, graceMs: number): Promise<StopOutcome> { + if (!signalGroup(pgid, "SIGTERM")) return groupAlive(pgid) ? "refused" : "stopped"; const deadline = Date.now() + graceMs; while (groupAlive(pgid) && Date.now() < deadline) await sleep(100); if (groupAlive(pgid)) { - signalGroup(pgid, "SIGKILL"); + if (!signalGroup(pgid, "SIGKILL")) return groupAlive(pgid) ? "refused" : "stopped"; const hardDeadline = Date.now() + 2_000; while (groupAlive(pgid) && Date.now() < hardDeadline) await sleep(50); } + return groupAlive(pgid) ? "survived" : "stopped"; +} + +/** + * The verdict for a group serve or stop meant to stop and could not, `why` + * saying why it was stopping it. Its record is kept, so a later make stop still + * finds it; one a failed start never recorded is named for a person to stop. + */ +function stillRunning(pgid: number, why: string, outcome: StopOutcome, recorded = true): string { + const cause = + outcome === "refused" + ? "the system refused to signal it, as a sandbox refuses a signal to a process started " + + "outside it" + : "it outlived SIGKILL"; + const then = recorded + ? `It is still running and still recorded in ${STATE_FILE}, so make stop run where it may ` + + "signal the group stops it." + : "It is still running and was never recorded, so make stop cannot find it: stop it with " + + `kill -- -${pgid} where that is allowed.`; + return ( + `failed: still-running — the server make serve started (process group ${pgid}) ${why}, ` + + `and could not be stopped: ${cause}. ${then}` + ); } // ── The state file ────────────────────────────────────────────────────────── @@ -405,17 +525,41 @@ function removeState(checkout: string): void { * Whether the group a state file names is still serve's server in this * checkout: `gone` when no process of it runs, `foreign` when its id now names * another group, or when its processes run elsewhere, as in a checkout copied - * with its `.serve/`. + * with its `.serve/`. A first process whose start time cannot be read is + * neither, and throws `NoPsError`; a running group none of whose processes + * lsof can see is neither too, and throws `NoLsofError`. */ -function ownership(lsof: string, state: ServeState, checkout: string): "ours" | "gone" | "foreign" { +function ownership( + config: ServeConfig, + state: ServeState, + checkout: string, +): "ours" | "gone" | "foreign" { if (!groupAlive(state.pgid)) return "gone"; // A group's id is its first process's. While that process runs it must be // the one serve started; once it has ended, the id is not given to another // process until the whole group has ended too, so the group is still serve's. - if (processAlive(state.pgid) && startTimeOf(state.pgid) !== state.leaderStart) return "foreign"; - const cwds = groupCwds(lsof, state.pgid); - if (cwds.length === 0) return "gone"; - return cwds.includes(checkout) ? "ours" : "foreign"; + const leaderStart = leaderStartOf(state.pgid, config.ps); + if (leaderStart !== undefined && leaderStart !== state.leaderStart) return "foreign"; + const cwds = groupCwds(config.lsof, state.pgid); + if (cwds.length > 0) return cwds.includes(checkout) ? "ours" : "foreign"; + // Nothing seen: the group has ended since, or lsof may not look at its + // processes, which proves neither way. + if (!groupAlive(state.pgid)) return "gone"; + throw new NoLsofError(state.pgid); +} + +/** + * The `refused: no-lsof` verdict for a recorded group that still runs where + * lsof sees none of its processes. + */ +function unseenGroup(config: ServeConfig, pgid: number): string { + return ( + `refused: no-lsof — process group ${pgid}, recorded in ${STATE_FILE}, still runs, but ` + + `${config.lsof} sees none of its processes, so whether it is the server make serve started ` + + "here cannot be told. Nothing was signalled or started, and the record was kept: run make " + + `stop where lsof can see it, or remove ${STATE_FILE} if that group is not this checkout's ` + + "server." + ); } // ── The page ──────────────────────────────────────────────────────────────── @@ -688,9 +832,23 @@ export async function serve(config: ServeConfig): Promise<number> { } catch (error) { if (error instanceof NoLsofError) { print( - `refused: no-lsof — make serve reads who holds the port with lsof, and ${config.lsof} ` + - "is not on the PATH or does not take lsof's options, as BusyBox's does not. Install " + - "lsof, or run make dev in the foreground.", + error.pgid !== undefined + ? unseenGroup(config, error.pgid) + : `refused: no-lsof — make serve reads who holds the port with lsof, and ${config.lsof} ` + + "is not on the PATH or does not take lsof's options, as BusyBox's does not. " + + "Install lsof, or run make dev in the foreground.", + ); + return EXIT_FAILED; + } + if (error instanceof NoPsError) { + print( + refusedNoPs( + config, + error, + "make serve reads when the server's first process started, to tell it later from a " + + "process given the same id", + "Nothing was started: run make serve where it can, or make dev in the foreground.", + ), ); return EXIT_FAILED; } @@ -698,6 +856,30 @@ export async function serve(config: ServeConfig): Promise<number> { } } +/** + * The `refused: no-ps` verdict. Without a pid, serve's own start time could not + * be read, and `need` says what serve or stop reads it for; with one, the + * recorded group still runs and its first process's could not, so the record + * is kept for a later run that can read it. + */ +function refusedNoPs(config: ServeConfig, error: NoPsError, need: string, then: string): string { + if (error.pid !== undefined) { + return ( + `refused: no-ps — process group ${error.pid}, recorded in ${STATE_FILE}, still runs, but ` + + `when its first process started cannot be read ${startTimeReader(config)}, so whether it ` + + "is the server make serve started cannot be told. Nothing was signalled or started, and " + + `the record was kept: run make stop where it can be read, or remove ${STATE_FILE} if that ` + + "group is not this checkout's server." + ); + } + const cannot = + process.platform === "linux" + ? "/proc cannot be read here" + : `${config.ps} is not on the PATH or may not run here, as a sandbox such as Codex's ` + + "refuses it"; + return `refused: no-ps — ${need}, and ${cannot}. ${then}`; +} + async function serveChecked( config: ServeConfig, checkout: string, @@ -706,11 +888,12 @@ async function serveChecked( ): Promise<number> { const { print, lsof } = config; requireLsof(lsof); + requirePs(config.ps); // The server serve started earlier, when it still runs here. const state = readState(checkout); if (state !== undefined) { - const owner = ownership(lsof, state, checkout); + const owner = ownership(config, state, checkout); if (owner === "ours") { const listening = readListeners(lsof, [state.port]).filter( (listener) => listener.pgid === state.pgid, @@ -723,7 +906,11 @@ async function serveChecked( print( `the server make serve started earlier (process group ${state.pgid}) is not listening; stopping it.`, ); - await stopGroup(state.pgid, config.graceMs); + const outcome = await stopGroup(state.pgid, config.graceMs); + if (outcome !== "stopped") { + print(stillRunning(state.pgid, "is not listening", outcome)); + return EXIT_FAILED; + } } removeState(checkout); } @@ -784,7 +971,13 @@ async function reportOwnServer( const { print } = config; const wide = beyond(groupListeners(config.lsof, state.pgid)); if (wide.length > 0) { - await stopGroup(state.pgid, config.graceMs); + const outcome = await stopGroup(state.pgid, config.graceMs); + if (outcome !== "stopped") { + print( + stillRunning(state.pgid, `listens beyond this machine (${addressesOf(wide)})`, outcome), + ); + return EXIT_FAILED; + } removeState(checkout); print( `refused: not-loopback — the server make serve started listened beyond this machine ` + @@ -807,9 +1000,13 @@ async function reportOwnServer( return EXIT_FAILED; } if (page.status !== 200) { - await stopGroup(state.pgid, config.graceMs); - removeState(checkout); + const outcome = await stopGroup(state.pgid, config.graceMs); printLogTail(config, path.join(checkout, LOG_FILE)); + if (outcome !== "stopped") { + print(stillRunning(state.pgid, `did not answer ${url} with 200`, outcome)); + return EXIT_FAILED; + } + removeState(checkout); print( `failed: page ${page.status} — the server make serve started earlier did not answer ${url} ` + "with 200, so it was stopped.", @@ -901,30 +1098,47 @@ async function start( // From here the group is stopped unless its page is proven: each verdict // below says why, and an error on the way stops it just the same before it // propagates. The server is in a session of its own, so the terminal's - // Ctrl-C does not reach it; the interruption `exclusive` catches does. + // Ctrl-C does not reach it; the interruption `exclusive` catches does. A + // group that outlives its stop keeps its record, once it has one, and the + // verdict says it still runs rather than the status it was stopped for. let settled = false; - const giveUp = async (verdict: string, tail = true): Promise<number> => { + let recorded = false; + const stopStarted = async (): Promise<StopOutcome> => { + const outcome = await stopGroup(pgid, config.graceMs); + if (outcome === "stopped") removeState(checkout); + return outcome; + }; + const giveUp = async (status: string, reason: string, tail = true): Promise<number> => { settled = true; - await stopGroup(pgid, config.graceMs); - removeState(checkout); + const outcome = await stopStarted(); if (tail) printLogTail(config, log); - print(verdict); + print( + outcome === "stopped" + ? `${status} — ${reason}` + : stillRunning(pgid, `failed to start (${status})`, outcome, recorded), + ); return EXIT_FAILED; }; - const interruptedVerdict = (signal = interrupt.received()) => - `failed: interrupted — ${signal} arrived before the page was proven, so the server was ` + - "stopped."; + const interrupted = (signal = interrupt.received()) => + giveUp( + "failed: interrupted", + `${signal} arrived before the page was proven, so the server was stopped.`, + false, + ); try { - const leaderStart = startTimeOf(pgid); + const leaderStart = leaderStartOf(pgid, config.ps); if (leaderStart === undefined) { return await giveUp( - `failed: exited — the dev server exited (${exit ?? "at once"}) before it could be recorded.`, + "failed: exited", + `the dev server exited (${exit ?? "at once"}) before it could be recorded.`, ); } const startedAt = new Date().toISOString(); - const record = (url: string) => + const record = (url: string) => { writeState(checkout, { pgid, leaderStart, port, url, log: LOG_FILE, checkout, startedAt }); + recorded = true; + }; record(urlOf(host.includes(":") ? `[${host}]` : host, port)); print(`starting the dev server on port ${port} (process group ${pgid}, log ${LOG_FILE})`); @@ -932,17 +1146,19 @@ async function start( const deadline = Date.now() + config.waitMs; let listening: Listener[] = []; for (;;) { - if (interrupt.received()) return await giveUp(interruptedVerdict(), false); + if (interrupt.received()) return await interrupted(); listening = readListeners(lsof, [port]).filter((listener) => listener.pgid === pgid); if (listening.length > 0) break; if (exit !== undefined) { return await giveUp( - `failed: exited — the dev server exited (${exit}) before it listened on port ${port}.`, + "failed: exited", + `the dev server exited (${exit}) before it listened on port ${port}.`, ); } if (Date.now() >= deadline) { return await giveUp( - `failed: not-listening — nothing of the dev server listened on port ${port} within ` + + "failed: not-listening", + `nothing of the dev server listened on port ${port} within ` + `${Math.round(config.waitMs / 1000)}s, so it was stopped with everything it started.`, ); } @@ -954,8 +1170,9 @@ async function start( const wide = beyond(groupListeners(lsof, pgid)); if (wide.length > 0) { return await giveUp( - `refused: not-loopback — the dev server listened beyond this machine ` + - `(${addressesOf(wide)}), so it was stopped before its page was requested.`, + "refused: not-loopback", + `the dev server listened beyond this machine (${addressesOf(wide)}), so it was stopped ` + + "before its page was requested.", false, ); } @@ -963,15 +1180,17 @@ async function start( const url = urlOf(loopbackAddressOf(listening), port); record(url); const page = await requestPage(url, config.pageMs, () => exit === undefined, interrupt.signal); - if (interrupt.received()) return await giveUp(interruptedVerdict(), false); + if (interrupt.received()) return await interrupted(); if (page.status === "exited") { return await giveUp( - `failed: exited — the dev server exited (${exit}) before its page answered.`, + "failed: exited", + `the dev server exited (${exit}) before its page answered.`, ); } if (page.status !== 200) { return await giveUp( - `failed: page ${page.status} — ${url} did not answer with 200, so the server was stopped.`, + `failed: page ${page.status}`, + `${url} did not answer with 200, so the server was stopped.`, ); } @@ -979,8 +1198,8 @@ async function start( const after = beyond(groupListeners(lsof, pgid)); if (after.length > 0) { return await giveUp( - `refused: not-loopback — the dev server listened beyond this machine ` + - `(${addressesOf(after)}), so it was stopped.`, + "refused: not-loopback", + `the dev server listened beyond this machine (${addressesOf(after)}), so it was stopped.`, false, ); } @@ -989,12 +1208,23 @@ async function start( print(`serving ${describePage(url, page.title)} (log ${LOG_FILE}); stop it with: make stop`); return EXIT_OK; } catch (error) { + if (error instanceof NoPsError) { + // Read for serve's own process a moment ago, so rare: the group could + // not be recorded, and one that is not recorded cannot be stopped later. + return await giveUp( + "failed: no-ps", + `when the dev server's first process (pid ${pgid}) started could not be read ` + + `${startTimeReader(config)}, so it could not be recorded, and it was stopped.`, + ); + } if (!(error instanceof HelperKilledError)) throw error; - return await giveUp(interruptedVerdict(error.signal), false); + return await interrupted(error.signal); } finally { if (!settled) { - await stopGroup(pgid, config.graceMs); - removeState(checkout); + const outcome = await stopStarted(); + if (outcome !== "stopped") { + print(stillRunning(pgid, "hit an error while starting", outcome, recorded)); + } } } } @@ -1012,13 +1242,30 @@ export async function stop(config: ServeConfig): Promise<number> { try { return await exclusive(config, checkout, () => stopChecked(config, checkout)); } catch (error) { - if (!(error instanceof NoLsofError)) throw error; - print( - "refused: no-lsof — make stop checks that the recorded process group still runs in this " + - `checkout before signalling it, and ${config.lsof} is not on the PATH or does not take ` + - "lsof's options, as BusyBox's does not.", - ); - return EXIT_FAILED; + if (error instanceof NoLsofError) { + print( + error.pgid !== undefined + ? unseenGroup(config, error.pgid) + : "refused: no-lsof — make stop checks that the recorded process group still runs in " + + `this checkout before signalling it, and ${config.lsof} is not on the PATH or does ` + + "not take lsof's options, as BusyBox's does not.", + ); + return EXIT_FAILED; + } + if (error instanceof NoPsError) { + print( + refusedNoPs( + config, + error, + "make stop signals the recorded process group only once its first process's start " + + "time proves it is the server make serve started", + `Nothing was signalled, and ${STATE_FILE} was kept, so make stop run where it can ` + + "still stops the server.", + ), + ); + return EXIT_FAILED; + } + throw error; } } @@ -1030,22 +1277,31 @@ async function stopChecked(config: ServeConfig, checkout: string): Promise<numbe return EXIT_OK; } requireLsof(config.lsof); - const owner = ownership(config.lsof, state, checkout); - removeState(checkout); + // Unlike serve, stop does not require ps up front: the record of a group that + // has ended is cleared without a start time, and ownership throws NoPsError + // for one that still runs and whose start cannot be read. + const owner = ownership(config, state, checkout); if (owner === "gone") { + removeState(checkout); print( `not-running — the server make serve started (process group ${state.pgid}) has already exited.`, ); return EXIT_OK; } if (owner === "foreign") { + removeState(checkout); print( `not-running — process group ${state.pgid} is no longer the server make serve started ` + "here, so it was left alone.", ); return EXIT_OK; } - await stopGroup(state.pgid, config.graceMs); + const outcome = await stopGroup(state.pgid, config.graceMs); + if (outcome !== "stopped") { + print(stillRunning(state.pgid, `serves ${state.url}`, outcome)); + return EXIT_FAILED; + } + removeState(checkout); print(`stopped ${state.url} (process group ${state.pgid})`); return EXIT_OK; } diff --git a/webapp-js/scripts/lib/serve.test.mts b/webapp-js/scripts/lib/serve.test.mts index 3c7d762..d7258a7 100644 --- a/webapp-js/scripts/lib/serve.test.mts +++ b/webapp-js/scripts/lib/serve.test.mts @@ -3,8 +3,8 @@ // `make serve` and `make stop`, each case against a fake dev server: a process // that forks a child to listen, as `next dev` forks `next-server`, so that // stopping "the server" is proven to stop everything it started. Every case -// runs in a checkout of its own, on ports found free, with the real lsof, and -// the cases that need lsof are skipped where there is none. +// runs in a checkout of its own, on ports found free, with the real lsof and +// ps, and the cases that need them are skipped where they cannot run. import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { @@ -23,7 +23,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { devScriptBindsLoopback, @@ -32,6 +32,7 @@ import { LOCK_FILE, lsofUsable, parseServeArgs, + psUsable, serve, startTimeOf, stop, @@ -49,6 +50,16 @@ const SPAWNS = { timeout: 60_000 }; // `make serve` gives there is tested anyway. const HAS_LSOF = lsofUsable("lsof"); +// Every case that starts a server reads its start time too, with ps outside +// Linux, and a sandbox may refuse to run it, as Codex's does on macOS. Those +// cases are skipped there, and the refusal is tested with a ps that cannot run. +const HAS_PS = psUsable("ps"); +const CAN_SERVE = HAS_LSOF && HAS_PS; + +// Only outside Linux is the start time read with ps, so only there does a ps +// that cannot run change what serve does. +const READS_PS = process.platform !== "linux"; + const TEMPLATE_DEV = "next dev -H ${APP_HOST:-127.0.0.1} -p ${APP_PORT:-4300}"; /** @@ -245,6 +256,7 @@ function configFor( pageMs: 8_000, graceMs: 2_000, lsof: "lsof", + ps: "ps", print: (line) => lines.push(line), lines, ...overrides, @@ -275,6 +287,36 @@ function lsofKilledOn(pattern: string): string { return lsof; } +/** A ps that cannot run, as a sandbox's refusal leaves it: the file is there, not executable. */ +function psThatCannotRun(): string { + const ps = path.join(tempDir("serve-ps-"), "ps"); + writeFileSync(ps, '#!/bin/sh\nexec ps "$@"\n'); + chmodSync(ps, 0o644); + return ps; +} + +/** + * Run `action` while every signal to a process group is refused, as a sandbox + * refuses one to a group started outside it; asking whether a group runs still + * answers. + */ +async function refusingGroupSignals<T>(action: () => Promise<T>): Promise<T> { + const kill = process.kill.bind(process); + const refusing = vi + .spyOn(process, "kill") + .mockImplementation((pid: number, signal?: string | number) => { + if (pid < 0 && signal !== 0) { + throw Object.assign(new Error("kill EPERM"), { code: "EPERM" }); + } + return kill(pid, signal); + }); + try { + return await action(); + } finally { + refusing.mockRestore(); + } +} + function stateOf(checkout: string): ServeState | undefined { const file = path.join(checkout, STATE_FILE); return existsSync(file) ? (JSON.parse(readFileSync(file, "utf-8")) as ServeState) : undefined; @@ -375,7 +417,22 @@ describe("what is refused or answered before a server is looked for", () => { expect(pidsIn(checkout)).toEqual([]); }); - it("reads a start time that does not depend on the shell's time zone", () => { + it.skipIf(!HAS_LSOF || !READS_PS)( + "refuses when ps cannot run, before starting anything", + async () => { + const checkout = checkoutWith(); + for (const ps of ["ps-that-is-not-installed", psThatCannotRun()]) { + const result = await run(serve, configFor(checkout, "ok", await freePorts(1), { ps })); + expect(result.code).toBe(1); + expect(result.verdict).toMatch( + /^refused: no-ps — make serve reads when the server's first process started, .* Nothing was started/, + ); + expect(pidsIn(checkout)).toEqual([]); + } + }, + ); + + it.skipIf(!HAS_PS)("reads a start time that does not depend on the shell's time zone", () => { const zone = process.env.TZ; try { process.env.TZ = "UTC"; @@ -389,22 +446,22 @@ describe("what is refused or answered before a server is looked for", () => { } }); - it.skipIf(process.platform === "linux")( + it.skipIf(!READS_PS)( "takes a ps killed by a signal for no answer, not for a process that has ended", () => { - const dir = tempDir("serve-ps-"); - writeFileSync(path.join(dir, "ps"), "#!/bin/sh\nkill -INT $$\n"); - chmodSync(path.join(dir, "ps"), 0o755); - const search = process.env.PATH; - try { - process.env.PATH = `${dir}${path.delimiter}${search}`; - expect(() => startTimeOf(process.pid)).toThrow(/^SIGINT killed ps before it answered$/); - } finally { - process.env.PATH = search; - } + const ps = path.join(tempDir("serve-ps-"), "ps"); + writeFileSync(ps, "#!/bin/sh\nkill -INT $$\n"); + chmodSync(ps, 0o755); + expect(() => startTimeOf(process.pid, ps)).toThrow(/^SIGINT killed ps before it answered$/); }, ); + it.skipIf(!READS_PS)("takes a ps that cannot run for no answer, and says it cannot read", () => { + expect(psUsable("ps-that-is-not-installed")).toBe(false); + expect(psUsable(psThatCannotRun())).toBe(false); + expect(() => startTimeOf(process.pid, psThatCannotRun())).toThrow("no start time"); + }); + it("says so when nothing was started", async () => { const checkout = checkoutWith(); const result = await run(stop, configFor(checkout, "ok", [])); @@ -415,7 +472,7 @@ describe("what is refused or answered before a server is looked for", () => { }); }); -describe.skipIf(!HAS_LSOF)("make serve", SPAWNS, () => { +describe.skipIf(!CAN_SERVE)("make serve", SPAWNS, () => { it("starts the server, proves its page, and reports it once", async () => { const checkout = checkoutWith(); const ports = await freePorts(2); @@ -619,6 +676,55 @@ describe.skipIf(!HAS_LSOF)("make serve", SPAWNS, () => { expect(await allGone(pidsIn(checkout))).toBe(true); }); + it("keeps the record of a server it could not stop once its start failed", async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "500", await freePorts(1)); + const result = await refusingGroupSignals(() => run(serve, config)); + const recorded = stateOf(checkout)!; + expect(result.code).toBe(1); + expect(result.verdict).toBe( + `failed: still-running — the server make serve started (process group ${recorded.pgid}) ` + + "failed to start (failed: page 500), and could not be stopped: the system refused to " + + "signal it, as a sandbox refuses a signal to a process started outside it. It is still " + + "running and still recorded in .serve/state.json, so make stop run where it may signal " + + "the group stops it.", + ); + expect(pidsIn(checkout).every(alive)).toBe(true); + + expect((await run(stop, config)).verdict).toMatch(/^stopped /); + expect(await allGone(pidsIn(checkout))).toBe(true); + }); + + it.skipIf(!READS_PS)("names a server it could not stop and never recorded", async () => { + const checkout = checkoutWith(); + // A ps that answers for serve's own process alone, so the server's start + // time cannot be read and the server cannot be recorded. + const ps = path.join(tempDir("serve-ps-"), "ps"); + writeFileSync( + ps, + `#!/bin/sh\ncase " $* " in *" ${process.pid} "*) exec ps "$@" ;; esac\nexit 1\n`, + ); + chmodSync(ps, 0o755); + const config = configFor(checkout, "ok", await freePorts(1), { ps }); + + const result = await refusingGroupSignals(() => run(serve, config)); + const pgid = Number(/process group (\d+)/.exec(result.verdict)?.[1]); + try { + expect(result.code).toBe(1); + expect(result.verdict).toBe( + `failed: still-running — the server make serve started (process group ${pgid}) failed ` + + "to start (failed: no-ps), and could not be stopped: the system refused to signal it, " + + "as a sandbox refuses a signal to a process started outside it. It is still running " + + `and was never recorded, so make stop cannot find it: stop it with kill -- -${pgid} ` + + "where that is allowed.", + ); + expect(stateOf(checkout)).toBeUndefined(); + expect(alive(pgid)).toBe(true); + } finally { + if (pgid > 1) kill(-pgid, "SIGKILL"); + } + }); + it("reports a server a person started here, and make stop leaves it alone", async () => { const checkout = checkoutWith(); const ports = await freePorts(2); @@ -665,7 +771,7 @@ describe.skipIf(!HAS_LSOF)("make serve", SPAWNS, () => { }); }); -describe.skipIf(!HAS_LSOF)("make stop", SPAWNS, () => { +describe.skipIf(!CAN_SERVE)("make stop", SPAWNS, () => { it("never signals a recorded group that no longer runs in this checkout", async () => { const checkout = checkoutWith(); const elsewhere = tempDir("serve-recycled-"); @@ -715,6 +821,190 @@ describe.skipIf(!HAS_LSOF)("make stop", SPAWNS, () => { expect(await allGone(pidsIn(checkout))).toBe(true); }); + it("keeps the record of a running group none of whose processes lsof can see", async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const recorded = stateOf(checkout)!; + // An lsof that finds nothing of the group, as one may not look at + // processes started outside its sandbox. + const lsof = path.join(tempDir("serve-lsof-"), "lsof"); + writeFileSync( + lsof, + `#!/bin/sh\ncase " $* " in *" -g ${recorded.pgid} "*) exit 1 ;; esac\nexec lsof "$@"\n`, + ); + chmodSync(lsof, 0o755); + + for (const action of [stop, serve]) { + const result = await run(action, { ...config, lsof }); + expect(result.code).toBe(1); + expect(result.verdict).toBe( + `refused: no-lsof — process group ${recorded.pgid}, recorded in .serve/state.json, still ` + + `runs, but ${lsof} sees none of its processes, so whether it is the server make serve ` + + "started here cannot be told. Nothing was signalled or started, and the record was " + + "kept: run make stop where lsof can see it, or remove .serve/state.json if that group " + + "is not this checkout's server.", + ); + expect(stateOf(checkout)).toEqual(recorded); + expect(pidsIn(checkout).every(alive)).toBe(true); + } + + expect((await run(stop, config)).verdict).toMatch(/^stopped /); + expect(await allGone(pidsIn(checkout))).toBe(true); + }); + + it.skipIf(!READS_PS)("keeps the record, and the server, when ps cannot run", async () => { + // Started where ps may run, then stopped where a sandbox refuses it. + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const recorded = stateOf(checkout); + const refused = { ...config, ps: psThatCannotRun() }; + + const stopping = await run(stop, refused); + expect(stopping.code).toBe(1); + expect(stopping.verdict).toMatch( + /^refused: no-ps — make stop signals the recorded process group only once .* Nothing was signalled, and \.serve\/state\.json was kept/, + ); + expect((await run(serve, refused)).verdict).toMatch(/^refused: no-ps — make serve reads when /); + expect(stateOf(checkout)).toEqual(recorded); + expect(pidsIn(checkout).every(alive)).toBe(true); + + expect((await run(stop, config)).verdict).toMatch(/^stopped /); + expect(await allGone(pidsIn(checkout))).toBe(true); + }); + + it.skipIf(!READS_PS)( + "clears the record of a server that has ended, even where ps cannot run", + async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const recorded = stateOf(checkout)!; + const pids = pidsIn(checkout); + for (const pid of pids) kill(pid, "SIGKILL"); + expect(await allGone(pids)).toBe(true); + + const result = await run(stop, { ...config, ps: psThatCannotRun() }); + expect(result.code).toBe(0); + expect(result.verdict).toBe( + `not-running — the server make serve started (process group ${recorded.pgid}) has ` + + "already exited.", + ); + expect(stateOf(checkout)).toBeUndefined(); + }, + ); + + it.skipIf(!READS_PS)( + "stops a server whose first process has ended without reading a start time", + async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const [parent, child] = pidsIn(checkout); + kill(parent, "SIGKILL"); + expect(await allGone([parent])).toBe(true); + expect(alive(child)).toBe(true); + + const result = await run(stop, { ...config, ps: psThatCannotRun() }); + expect(result.verdict).toMatch(/^stopped /); + expect(await allGone([child])).toBe(true); + expect(stateOf(checkout)).toBeUndefined(); + }, + ); + + it.skipIf(!READS_PS)( + "never takes a start time it cannot read for another process's", + async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const recorded = stateOf(checkout)!; + // A ps that runs, and answers for every process but the group's first, + // as one that cannot see it would: nothing, and a failure. + const ps = path.join(tempDir("serve-ps-"), "ps"); + writeFileSync( + ps, + `#!/bin/sh\ncase " $* " in *" ${recorded.pgid} "*) exit 1 ;; esac\nexec ps "$@"\n`, + ); + chmodSync(ps, 0o755); + + for (const action of [stop, serve]) { + const result = await run(action, { ...config, ps }); + expect(result.code).toBe(1); + expect(result.verdict).toMatch( + new RegExp( + `^refused: no-ps — process group ${recorded.pgid}, recorded in \\.serve/state\\.json, ` + + "still runs, but when its first process started cannot be read", + ), + ); + expect(stateOf(checkout)).toEqual(recorded); + expect(pidsIn(checkout).every(alive)).toBe(true); + } + + expect((await run(stop, config)).verdict).toMatch(/^stopped /); + expect(await allGone(pidsIn(checkout))).toBe(true); + }, + ); + + it("keeps the record of a server the system refuses to let it signal", async () => { + const checkout = checkoutWith(); + const config = configFor(checkout, "ok", await freePorts(1)); + const started = await run(serve, config); + expect(started.code, started.lines.join("\n")).toBe(0); + const recorded = stateOf(checkout)!; + const [parent, child] = pidsIn(checkout); + + // As a sandbox answers a signal to a group started outside it. + const kill = process.kill.bind(process); + const refusing = vi + .spyOn(process, "kill") + .mockImplementation((pid: number, signal?: string | number) => { + if (pid === -recorded.pgid) { + throw Object.assign(new Error("kill EPERM"), { code: "EPERM" }); + } + return kill(pid, signal); + }); + try { + const stopping = await run(stop, config); + expect(stopping.code).toBe(1); + expect(stopping.verdict).toBe( + `failed: still-running — the server make serve started (process group ${recorded.pgid}) ` + + `serves ${recorded.url}, and could not be stopped: the system refused to signal it, as ` + + "a sandbox refuses a signal to a process started outside it. It is still running and " + + "still recorded in .serve/state.json, so make stop run where it may signal the group " + + "stops it.", + ); + expect(stateOf(checkout)).toEqual(recorded); + + // Ours, and not listening: serve means to stop it before starting afresh. + kill(child, "SIGKILL"); + expect(await allGone([child])).toBe(true); + const serving = await run(serve, config); + expect(serving.code).toBe(1); + expect(serving.verdict).toMatch( + new RegExp( + "^failed: still-running — the server make serve started \\(process group " + + `${recorded.pgid}\\) is not listening, and could not be stopped`, + ), + ); + expect(stateOf(checkout)).toEqual(recorded); + expect(alive(parent)).toBe(true); + } finally { + refusing.mockRestore(); + } + + const restarted = await run(serve, config); + expect(restarted.verdict, restarted.lines.join("\n")).toMatch(/^serving /); + expect(alive(parent)).toBe(false); + expect((await run(stop, config)).verdict).toMatch(/^stopped /); + }); + it("never signals a recorded group whose first process is another one, even here", async () => { // A stale record whose id now names a process started in this checkout — // a shell, an editor, or the make running the command. @@ -742,7 +1032,7 @@ describe.skipIf(!HAS_LSOF)("make stop", SPAWNS, () => { }); }); -describe.skipIf(!HAS_LSOF)("the command that started it", SPAWNS, () => { +describe.skipIf(!CAN_SERVE)("the command that started it", SPAWNS, () => { const LIB = path.join(import.meta.dirname, "serve.mts"); /** Run serve in a process of its own, as `make serve` does, and hand it back. */ @@ -759,7 +1049,7 @@ describe.skipIf(!HAS_LSOF)("the command that started it", SPAWNS, () => { `process.exitCode = await serve({ checkout: ${JSON.stringify(checkout)}, ` + `command: [process.execPath, "fake-dev.mjs", "parent", ${JSON.stringify(mode)}], ` + `host: "127.0.0.1", ports: ${JSON.stringify(ports)}, waitMs: ${waitMs}, pageMs: 8000, ` + - `graceMs: 2000, lsof: "lsof", print: (line) => console.log(line) });\n`, + `graceMs: 2000, lsof: "lsof", ps: "ps", print: (line) => console.log(line) });\n`, ); const child = spawn(process.execPath, ["--experimental-strip-types", "--no-warnings", script], { cwd: checkout, From adc953b4f0cc02dbbff5621452d917a115f86a39 Mon Sep 17 00:00:00 2001 From: Louis Choquel <louis@pipelex.com> Date: Fri, 25 Sep 2026 13:00:21 +0200 Subject: [PATCH 2/2] Release v0.5.5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 2 +- VERSION | 2 +- initializers/js/package.json | 2 +- webapp-js/package-lock.json | 4 ++-- webapp-js/package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e54f2..51bf449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.5.5] - 2026-09-25 ### Fixed diff --git a/VERSION b/VERSION index 7d85683..d1d899f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.4 +0.5.5 diff --git a/initializers/js/package.json b/initializers/js/package.json index bf73bca..bdbe810 100644 --- a/initializers/js/package.json +++ b/initializers/js/package.json @@ -1,6 +1,6 @@ { "name": "@pipelex/create-method-app", - "version": "0.5.4", + "version": "0.5.5", "description": "Start a Pipelex method app: npm create @pipelex/method-app@latest my-app -- --method <bundle | mt_… | address>", "keywords": [ "pipelex", diff --git a/webapp-js/package-lock.json b/webapp-js/package-lock.json index 361e2c8..16c9d81 100644 --- a/webapp-js/package-lock.json +++ b/webapp-js/package-lock.json @@ -1,12 +1,12 @@ { "name": "pipelex-method-webapp-js", - "version": "0.5.4", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pipelex-method-webapp-js", - "version": "0.5.4", + "version": "0.5.5", "dependencies": { "@pipelex/mthds-form": "^0.10.0", "@pipelex/sdk": "^0.25.1", diff --git a/webapp-js/package.json b/webapp-js/package.json index 848d0b4..5cbb8c1 100644 --- a/webapp-js/package.json +++ b/webapp-js/package.json @@ -1,6 +1,6 @@ { "name": "pipelex-method-webapp-js", - "version": "0.5.4", + "version": "0.5.5", "private": true, "description": "A Next.js app that runs MTHDS methods through the Pipelex API, with each method's input form and result view generated from its own contract.", "scripts": {