Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 seeWhat it meansWhere to look
ExitCodeError, and the code is expectedgrep found nothing (1), diff found a difference (1)Accepting expected exit codes
ExitCodeError: grep exited with code 1, no reasonthe reason went to stderrPutting stderr into the error
UpstreamError, or an error with a causean earlier command or callback failedWhich error a pipeline throws
SignalErrorthe command was killedKilled by a signal
TimeoutErrorthe command ran past its timeoutMsTimed out
NotFound from run() or read()the program or file isn’t thereA missing program or file
NotCapable: Requires run accessDeno’s permissionsInstall
RangeError: .first: the sequence is empty.first on empty outputReading the output
no error, the program just hangsnothing reads a command’s outputRunning 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, and cause is the earlier command’s error. UpstreamError.command is the command that threw, not the one that failed; the message is copied from the cause.
  • Both fail: the last command throws its own ExitCodeError (or SignalError), still with the earlier error as cause. This is common, since a command that gets cut-off or empty input often fails too: in the second case, grep exits 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:

  • fnStderr receives the child’s stderr as bytes (use .lines for 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.
  • fnError decides 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 (or undefined) and what fnStderr returned. Whatever it throws is what the consumer’s await throws; set cause when you wrap the original. If it returns normally, nothing is thrown. It may be async.

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.