@centient/proc
v0.1.0
Published
Hardened subprocess runner with timeouts, kill escalation, buffer caps, and unified typed errors
Maintainers
Readme
@centient/proc
Hardened subprocess runner for Centient packages. Wraps node:child_process
spawn with the things every robust process call needs and nobody wants to
re-derive: wall-clock timeouts, SIGTERM-then-SIGKILL kill escalation, per-stream
output buffer caps, AbortSignal cancellation, stdin streaming, and a single
typed error that tells you exactly how a run failed.
Zero external runtime dependencies. ESM-only. Binary-agnostic — you pass the executable; the runner knows nothing about any particular tool.
Installation
npm install @centient/procOr as a workspace dependency in the monorepo:
pnpm add @centient/proc --workspaceWhy
child_process.execFile gives you a timeout and a maxBuffer, but it conflates
every failure into a loosely-typed error, kills only with SIGKILL, and offers
no AbortSignal story without extra plumbing. @centient/proc settles those
concerns once:
- Settle-once semantics. The returned promise resolves or rejects exactly
once. Timeout, abort, buffer-overflow, spawn-failure, and the child's own
close/errorevents all race through a single gate; the first terminal event wins and the rest are no-ops. This is a tested invariant, not a hope. - Kill escalation. On timeout or abort the runner sends
SIGTERM, waits a configurable grace window, thenSIGKILL. It never relies on the child cooperating. - Buffer caps. stdout and stderr each have an independent byte cap; exceeding it kills the process rather than letting memory grow unbounded.
- Unified typed error. Every failure is a single
ProcErrorwhosekinddiscriminatesspawn-failure/non-zero-exit/timeout/signal/buffer-overflow/aborted/stdin-error. No message parsing. - No shell, ever.
commandandargsgo straight tospawnas a program image and argument vector — the runner never setsshell: true. Nothing is tokenised, glob-expanded, or variable-interpolated, so there is no shell metacharacter to escape and nocommand/argsinjection surface. To run shell syntax, invoke the shell explicitly:runProcess("/bin/sh", { args: ["-c", script] })and own the quoting ofscript. The one shell-agnostic injection vector — a NUL byte (\0) in the command or any arg, which would silently truncate the C-string the syscall sees — is rejected eagerly with aspawn-failure; every other byte is passed through verbatim. - Injectable clock and spawn. Timeouts and kill escalation are driven by an
injectable
Clock, andspawnitself is injectable — so the hard paths are tested deterministically without real sleeps or real processes.
Quick Start
import { runProcess, ProcError, isProcError } from "@centient/proc";
try {
const result = await runProcess("git", {
args: ["rev-parse", "HEAD"],
timeoutMs: 5_000,
});
console.log(result.stdout.toString().trim()); // the commit SHA
console.log(result.exitCode); // 0
} catch (err) {
if (isProcError(err)) {
switch (err.kind) {
case "spawn-failure":
console.error("git not found on PATH");
break;
case "non-zero-exit":
console.error(`git exited ${err.exitCode}:`, err.stderr);
break;
case "timeout":
console.error(`git ran longer than ${err.timeoutMs}ms`);
break;
// signal | buffer-overflow | aborted ...
}
}
}Streaming stdin
// Pipe data in; the runner writes it and closes the pipe.
const formatted = await runProcess("prettier", {
args: ["--parser", "typescript"],
input: sourceCode,
});Cancellation
const ac = new AbortController();
const run = runProcess("long-running-tool", { signal: ac.signal });
// later — kills SIGTERM then SIGKILL after the grace window:
ac.abort();
await run; // rejects with a ProcError of kind "aborted"If the signal is already aborted, runProcess rejects before spawning.
Binary output
const { stdout } = await runProcess("convert", {
args: ["in.png", "out:-"],
encoding: "buffer", // stdout/stderr come back as Buffers
});API
runProcess(command, options?): Promise<ProcResult>
| Option | Type | Default | Description |
| ---------------- | -------------------------- | ----------- | ------------------------------------------------------------------ |
| args | readonly string[] | [] | Argument vector. Passed verbatim — never shell-interpreted. |
| timeoutMs | number | disabled | Wall-clock limit. On expiry: kill escalation + timeout error. |
| killGraceMs | number | 5000 | Delay between SIGTERM and SIGKILL when the runner kills. 0 skips SIGTERM and sends SIGKILL immediately. |
| maxStdoutBytes | number | 10485760 | stdout byte cap; exceeding it kills with buffer-overflow. |
| maxStderrBytes | number | 10485760 | stderr byte cap; exceeding it kills with buffer-overflow. |
| input | string \| Buffer | — | Streamed to the child's stdin; the pipe is then closed. |
| encoding | BufferEncoding \| "buffer" | "utf8" | Decode output with any Node BufferEncoding (utf8, hex, base64, latin1, …), or "buffer" for raw Buffers. Decode is applied once to the full stream. |
| cwd | string | — | Working directory for the child. |
| env | NodeJS.ProcessEnv | inherited | Environment for the child. |
| signal | AbortSignal | — | Cancels the run; kills the process and rejects with aborted. |
| spawnImpl | SpawnImpl | spawn | Inject a custom spawn (testing). |
| clock | Clock | global | Inject a custom clock for timeout/kill timers (testing). |
Resolves with ProcResult (stdout, stderr, exitCode: 0, signal: null)
on a clean run, or rejects with a ProcError.
ProcError
A single error class. kind is the discriminant:
| kind | Meaning |
| ----------------- | ----------------------------------------------------------- |
| spawn-failure | Process could not be started (e.g. ENOENT, EACCES). |
| non-zero-exit | Started and exited with a non-zero code (exitCode set). |
| timeout | Exceeded timeoutMs and was killed (timeoutMs set). |
| signal | Terminated by an external signal (signal set). |
| buffer-overflow | stdout/stderr exceeded its cap (limitBytes + actualBytes set). |
| aborted | The AbortSignal fired. |
| stdin-error | Writing input to stdin failed with an unexpected I/O error (e.g. ENOSPC) on an otherwise-clean exit (cause set). Pipe teardown (EPIPE) is not reported. |
ProcError also carries command, args, and best-effort stdout/stderr
captured before the failure. Use isProcError(value) to narrow.
Design notes
node:child_processonly. No external runtime dependencies, ever.- No ambient clock/randomness in core paths. All timing flows through the
injectable
Clock, keeping the runner observable and deterministically testable (observable-architecture principle). command/argsare never shell-interpreted, so callers are not exposed to shell injection through this package.
License
MIT
