Managing a Process
Spawn correctness, an always-drained output pipeline, readiness, restart policy, and a stop ladder that ends the whole tree — the same on Windows, Linux, WSL, and macOS.
@nice-code/process manages one child process properly. Not a task runner, not a supervisor —
one process, with the parts that are easy to get subtly wrong done once: argv-first spawn, an
output pipeline that never blocks the child, readiness you can await, and a stop that ends the
whole tree rather than the pid you happen to hold.
bun add @nice-code/processimport { createNiceProcess } from "@nice-code/process";
const web = createNiceProcess({ id: "web", run: ["bun", "run", "dev"], cwd: "./apps/web", ready: { kind: "port", port: 5173 },});
await web.start();await web.whenReady();// …await web.stop();Spawn is argv-first
Section titled “Spawn is argv-first”run is an array, and it is passed through untouched — no shell, no splitting, no quoting
rules to lose an argument to:
run: ["node", "server.js", "--name", "my app"] // one argument, spaces and allA string needs an explicit shell, because a string is a shell program, not a command:
run: "tsc --noEmit && biome check .",shell: true,A constrained string with no metacharacters is split for you, but anything quoted or piped is rejected with a pointer at the two real forms rather than being split wrong and run anyway.
On Windows, .cmd/.bat shims resolve through PATH + PATHEXT and are wrapped in cmd.exe
with verbatim arguments, so a shim’s arguments survive its own quoting rules.
Readiness is a contract, not a sleep
Section titled “Readiness is a contract, not a sleep”ready: { kind: "logMatch", match: "ready in" } // a banner on stdout/stderrready: { kind: "port", port: 5173 } // something is really listeningready: { kind: "httpOk", port: 8787, path: "/health" } // …and it answersready: { kind: "delay", ms: 500 } // last resortready: { kind: "exit0" } // for a one-shot taskwhenReady() resolves when the contract is met and rejects with a typed reason otherwise —
and it is bound to the current run, so a restart never resolves a previous run’s waiter.
Two behaviours worth knowing:
- Readiness latches after a stability window. A port that opens and closes during a startup
crash is not ready. A
healthProbeMskeeps probing afterwards, and a later failure moves the process tounreadyrather than pretending it is still up. - A readiness timeout never kills anything. The process keeps running and is marked
unreadywith the reason. A slow cold start is not a crash, and the log is right there to explain it.
logMatch is matched per line with ANSI removed. Match something short and early — a line longer
than log.lineBytes (8 KiB) arrives as bounded segments, so a pattern buried in a huge JSON
payload can straddle a boundary and never match.
Output: bounded, ordered, never blocking
Section titled “Output: bounded, ordered, never blocking”The pipe buffer is the child’s backpressure, so we always drain it. Every line carries a
monotonic seq, a stable id, a wall-clock ts (display only) and a monotonic at (ordering).
for await (const line of web.lines()) { process.stdout.write(`${line.text}\n`);}const history = web.ring(); // the bounded tail, always available- A bare
\rbecomes areplaceop on the same line id, so a progress bar is one line that updates rather than ten thousand lines. - A 10 MB line with no newline arrives as capped continuation frames — never one unbounded
buffer waiting for a
\n. - A consumer that stops reading gets an explicit gap marker, not silent loss and not unbounded memory:
for await (const line of web.lines({ maxQueuedBytes: 64_000, onGap: (n) => warn(`${n} dropped`) })) {}seq and line ids continue across restarts, because the ring outlives a run: a spinner in the
second run can never overwrite a line from the first.
Stopping ends the tree
Section titled “Stopping ends the tree”stop() walks a ladder and verifies at each rung — optional stopInput (a key some tools take as
“quit”), then SIGINT → SIGTERM → SIGKILL on POSIX, against the process group; on Windows, a
Job Object owns the tree, with taskkill /T /F as an honestly-reported degraded tier.
const capability = web.capability;// { treeMechanism: "job-object" | "process-group" | "taskkill-degraded", degraded, notes }This is the part most code gets wrong: killing child.pid for npm run dev kills the shim and
leaves the actual server holding the port. A dead root is not a dead tree, and stop() reaps a
surviving descendant after a voluntary root exit.
stop() is idempotent, safe during starting (it waits for the spawn to settle first), and
reports whether the exit was expected — so a UI never paints a stop you asked for as a failure.
Restarts have a budget
Section titled “Restarts have a budget”restart: { on: "unexpected-exit", backoff: { initialMs: 500, maxMs: 15_000, jitter: true }, maxAttempts: 5, windowMs: 60_000, stableAfterReadyMs: 10_000,}Jitter matters: ten processes restarting in lockstep after a laptop wakes is a thundering herd.
The budget resets only after the process has genuinely served for stableAfterReadyMs.
beforeReady defaults to false. A service that dies before its first readiness is almost
always misconfigured, and many start commands mutate the tree (wrangler types, a formatter
pass) — retrying those blindly re-mutates it. Opt in per process when a pre-ready retry is
genuinely right.
Backoff and uptime use a monotonic clock, so a laptop sleeping or NTP stepping the clock cannot collapse a deadline.
The ledger: orphans, and never killing the wrong thing
Section titled “The ledger: orphans, and never killing the wrong thing”Attach a ledger and every spawn records {pid, startStamp, treeId}. A later start reaps what a
crashed owner left behind.
import { openProcessLedger } from "@nice-code/process";const ledger = await openProcessLedger({ dir: "./.nice/ledger", namespace: "my-app" });The rule that makes this safe: a pid match alone never authorizes a kill. Pids are reused. A record whose fingerprint no longer matches is quarantined — surfaced, never killed. A record whose owner is still alive is an execution lease and is left strictly alone.
Logs on disk
Section titled “Logs on disk”log: { file: { dir: "./.logs", keepRuns: 5, maxTotalBytes: 50_000_000 } }Segmented per run with retention. If the disk fills or a write fails, it degrades to ring-only with an in-band marker — it never crashes and never blocks the child.