Errors
import { ExitCodeError, run } from "@j50n/proc";
try {
await run("sh", "-c", "echo partial; exit 3").lines.forEach(console.log);
} catch (error) {
if (error instanceof ExitCodeError) {
console.log(error.message);
console.log(error.command, error.code);
} else {
throw error;
}
}
partial
sh exited with code 3
[ "sh", "-c", "echo partial; exit 3" ] 3
A command that exits with a non-zero code throws
ExitCodeError from the
consumer’s await, after every line it wrote has been delivered: partial
printed first. Its message names the program and the code; command holds the
whole command line (left out of the message, since arguments can hold secrets),
and code the exit code. One try around the consumer catches the errors of
every command and every callback in the pipeline.
What you see, and what to do
| What you see | What it means | Where to look |
|---|---|---|
ExitCodeError, and the code is expected | grep found nothing (1), diff found a difference (1) | Accepting expected exit codes |
ExitCodeError: grep exited with code 1, no reason | the reason went to stderr | Putting stderr into the error |
UpstreamError, or an error with a cause | an earlier command or callback failed | Which error a pipeline throws |
SignalError | the command was killed | Killed by a signal |
TimeoutError | the command ran past its timeoutMs | Timed out |
NotFound from run() or read() | the program or file isn’t there | A missing program or file |
NotCapable: Requires run access | Deno’s permissions | Install |
RangeError: .first: the sequence is empty | .first on empty output | Reading the output |
| no error, the program just hangs | nothing reads a command’s output | Running a command |
All four process errors extend
ProcessError, so
error instanceof ProcessError catches any of them. Narrow with instanceof
before using code, signal, or command.
Killed by a signal
import { run, SignalError } from "@j50n/proc";
try {
// The shell kills itself, as the OOM killer or `kill -9` might.
await run("sh", "-c", "echo started; kill -KILL $$").lines.forEach(
console.log,
);
} catch (error) {
if (error instanceof SignalError) {
console.log(`${error.command[0]} was killed by ${error.signal}`);
} else {
throw error;
}
}
started
sh was killed by SIGKILL
A command killed by a signal throws
SignalError, with the signal’s
name in signal. SIGKILL usually means the out-of-memory killer or a
kill -9; SIGTERM means something asked it to stop. A command you stopped
early yourself (with take, first, or break) dies of SIGPIPE, but that
throws nothing.
Timed out
import { run, TimeoutError } from "@j50n/proc";
try {
await run({ timeoutMs: 200 }, "sleep", "5").lines.collect();
} catch (error) {
if (error instanceof TimeoutError) console.log(error.message);
else throw error;
}
sleep timed out after 200 ms
With timeoutMs, proc sends the child SIGTERM when the time runs out, and its
output throws TimeoutError
once the child has exited, even if it caught the signal and exited cleanly: a
run that was cut short didn’t succeed. The timer runs from the start until the
child exits, so it also stops a child whose output you stopped reading early. A
child that ignores SIGTERM keeps running; proc never sends SIGKILL. And reading
waits for the output to end, which a child’s own children can hold open, as a
wrapper script’s do (see
Wrapper scripts must exec).
Which error a pipeline throws
import { ExitCodeError, run, UpstreamError } from "@j50n/proc";
/** One line per error in the cause chain. */
function chain(error: unknown): string[] {
const lines: string[] = [];
for (let e = error; e instanceof Error; e = e.cause) {
if (e instanceof ExitCodeError) {
lines.push(`${e.name}: ${e.command[0]} exited with ${e.code}`);
} else if (e instanceof UpstreamError) {
lines.push(`${e.name}: thrown by ${e.command[0]}`);
} else {
lines.push(`${e.name}: ${e.message}`);
}
}
return lines;
}
// The first command fails; the last one succeeds.
try {
await run("sh", "-c", "echo data; exit 2").run("cat").lines.collect();
} catch (error) {
console.log(chain(error));
}
// Both fail: grep finds nothing in the empty input and exits 1.
try {
await run("sh", "-c", "exit 2").run("grep", "data").lines.collect();
} catch (error) {
console.log(chain(error));
}
[ "UpstreamError: thrown by cat", "ExitCodeError: sh exited with 2" ]
[
"ExitCodeError: grep exited with 1",
"ExitCodeError: sh exited with 2"
]
The consumer sees only the last command, so an earlier failure reaches it
through the chain, as the error’s cause:
- An earlier command fails and the last one succeeds: the last command
throws
UpstreamError, andcauseis the earlier command’s error.UpstreamError.commandis the command that threw, not the one that failed; themessageis copied from the cause. - Both fail: the last command throws its own
ExitCodeError(orSignalError), still with the earlier error ascause. This is common, since a command that gets cut-off or empty input often fails too: in the second case,grepexits 1 because it saw nothing to match.
In a longer pipeline the chain is longer, one link per command, in order from
the last command back to the first failure. Walk cause to find where it
started, as chain() does above.
Putting stderr into the error
import { ExitCodeError, run } from "@j50n/proc";
try {
await run(
{
fnStderr: (stderr) => stderr.lines.collect(),
fnError: (error, stderrLines) => {
if (error instanceof ExitCodeError) {
const detail = stderrLines?.join("; ") ?? "";
throw new Error(`backup failed (exit ${error.code}): ${detail}`, {
cause: error,
});
}
if (error) throw error;
},
},
"sh",
"-c",
"echo 'writing archive' >&2; echo 'disk full' >&2; exit 4",
).lines.collect();
} catch (error) {
if (error instanceof Error) console.log(error.message);
}
backup failed (exit 4): writing archive; disk full
Two options, passed before the command, work together:
fnStderrreceives the child’s stderr as bytes (use.linesfor text) and returns whatever you want to keep. With it, stderr no longer goes to the terminal. Read it to the end: a child that fills its stderr pipe (about 64 KB) blocks, and the program hangs.fnErrordecides what the process throws. It is called once, after the output has ended and the child has exited, with the error the process would throw (orundefined) and whatfnStderrreturned. Whatever it throws is what the consumer’sawaitthrows; setcausewhen you wrap the original. If it returns normally, nothing is thrown. It may beasync.
Don’t throw from fnStderr to fail the process: that replaces the process’s own
error, and the exit code is lost. Return the data, and throw from fnError.
ErrorHandler and
StderrHandler have the exact
rules.
Accepting expected exit codes
import { ExitCodeError, run } from "@j50n/proc";
/** grep, where exit code 1 ("no lines matched") is not an error. */
function grep(pattern: string, file: string): Promise<string[]> {
return run(
{
fnError: (error) => {
if (error instanceof ExitCodeError && error.code === 1) return;
if (error) throw error;
},
},
"grep",
pattern,
file,
).lines.collect();
}
console.log(await grep("WARN", "app.log"));
console.log(await grep("FATAL", "app.log"));
try {
await grep("WARN", "missing.log"); // exit 2: a real failure
} catch (error) {
if (error instanceof ExitCodeError) console.log(`exit ${error.code}`);
}
// When only the answer matters, `grep -q` prints nothing; ask the status.
const { success } = await run("grep", "-q", "ERROR", "app.log").status;
console.log(success);
[ "2026-10-05 09:01:14 WARN retrying db connection" ]
[]
exit 2
true
grep exits 1 when no line matches and 2 when something went wrong, such as a
missing file. An fnError that returns for code 1 and rethrows everything else
turns “no match” into an empty result and keeps real failures as errors. (The
missing file’s message went to the terminal, from grep’s stderr.) Other commands
with a meaningful code 1: diff (files differ), cmp, test.
If you only need the answer, not the output, ask the status instead: grep -q
prints nothing, so .status can be awaited without reading anything, and it
reports a failed exit rather than throwing it.
Catching the error afterward works too, but the consumer has thrown by then, so
any output it collected is lost. In a pipeline, put fnError on the command
that has the expected code (.run({ fnError }, "grep", ...)); the commands
after it then see no failure.
Errors from your own callbacks
import { enumerate, UpstreamError } from "@j50n/proc";
function parse(line: string): number {
const n = Number(line);
if (Number.isNaN(n)) throw new Error(`not a number: ${line}`);
return n;
}
// The callback's error arrives as it was thrown.
try {
await enumerate(["1", "two", "3"]).map(parse).collect();
} catch (error) {
if (error instanceof Error) console.log(`${error.name}: ${error.message}`);
}
// With a .run() after the callback, it is the cause of an UpstreamError.
try {
await enumerate(["1", "two", "3"])
.map(parse)
.map((n) => `${n}`)
.run("cat")
.lines
.collect();
} catch (error) {
if (error instanceof UpstreamError && error.cause instanceof Error) {
console.log(
`${error.name} from ${error.command[0]}: ${error.cause.message}`,
);
}
}
Error: not a number: two
UpstreamError from cat: not a number: two
An error thrown in a callback (map, filter, forEach, …) stops the
pipeline and arrives at the consumer as it was thrown. If a .run() comes after
the callback, the error stops that command’s input, and the command throws
UpstreamError with your error as its cause, as for a failed command
upstream. The same try catches both.
A missing program or file
import { read, run } from "@j50n/proc";
try {
await run("no-such-program", "--version").lines.collect();
} catch (error) {
if (error instanceof Deno.errors.NotFound) console.log(error.message);
}
try {
await read("no-such-file.txt").lines.collect();
} catch (error) {
if (error instanceof Deno.errors.NotFound) console.log(error.message);
}
Failed to spawn 'no-such-program': entity not found
No such file or directory (os error 2): open 'no-such-file.txt'
Both throw Deno.errors.NotFound, at different times. run() throws it itself,
at the call, before any consumer runs; keep the call inside the try. A cwd
that doesn’t exist throws it from run() too, with No such cwd in the
message. read() throws it from the consumer, when reading starts. Fed into a
command with .run(), a missing file becomes the cause of that command’s
error
(Feeding a command from a file).
Stopping early skips the check
import { run } from "@j50n/proc";
// The command fails, but the consumer stopped before the end, so the exit
// code is never checked and nothing throws.
const first = await run("sh", "-c", "echo one; echo two; exit 1").lines.first;
console.log(first);
one
A consumer that stops before the end (first, take, find, some, a
break) closes the command’s output, and its exit code is never checked. If the
failure matters, read to the end. The same goes for a command cut short by one
after it, such as head; see Stopping early.