Skip to content

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.

Terminal window
bun add @nice-code/process
import { 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();

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 all

A 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.

ready: { kind: "logMatch", match: "ready in" } // a banner on stdout/stderr
ready: { kind: "port", port: 5173 } // something is really listening
ready: { kind: "httpOk", port: 8787, path: "/health" } // …and it answers
ready: { kind: "delay", ms: 500 } // last resort
ready: { kind: "exit0" } // for a one-shot task

whenReady() 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 healthProbeMs keeps probing afterwards, and a later failure moves the process to unready rather than pretending it is still up.
  • A readiness timeout never kills anything. The process keeps running and is marked unready with 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.

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 \r becomes a replace op 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.

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.

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.

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.