Skip to content

Errors

How failures propagate through the system. Three layers, each with its own error contract, connected by clear translation rules.

Error Taxonomy

Filesystem Errors (FsError)

Thrown by fileservers, propagated through kernel to processes. Defined in the fileservers spec.

class FsError extends Error {
constructor(public code: string, message: string) {
super(message)
}
}
CodeMeaningTypical source
ENOENTNo such file or directoryopen, stat, readdir on missing path
EEXISTAlready existsopen with create on existing file
EISDIRIs a directoryread/write on a directory
ENOTDIRNot a directoryreaddir on a file
EPERMOperation not permittedwrite to read-only server
ENOSPCNo space leftwrite when server enforces limits
EBADFBad file descriptorread/write/close on unknown fd
EINVALInvalid argumentbad flags, invalid data

Process Errors (ProcError)

Thrown by the kernel for process-related operations.

class ProcError extends Error {
constructor(public code: string, message: string) {
super(message)
}
}
CodeMeaningTypical source
ESRCHNo such processsignal/wait on nonexistent pid
ECHILDNot a child processwait on a process that isn’t yours

Pipe Errors (EPIPE)

EPIPE is thrown as a FsError when writing to a pipe whose read end is closed. Covered in the pipes spec. The kernel delivers SIGPIPE to the process; default handler kills it with exit code 141.

System Errors

Decision: no system-level resource errors in MVP. No EMFILE (too many fds), no ENOMEM (out of memory). These exist to enforce limits we don’t have. The process table, fd table, and memory are bounded only by the JS heap. If we add resource limits later (e.g., max fds per process), we add the corresponding errors then. Inventing errors for unenforced limits is misleading.

Representation

Decision: two Error subclasses (FsError, ProcError), not one. They live in different domains — filesystem vs process management. A single UnixError would work but loses type information. Two classes let TypeScript narrow on instanceof while sharing the same code string pattern. No deeper hierarchy (no EnoentError, EpermError, etc.) — code strings handle discrimination.

// Catching by domain
try {
await proc.fs.open('/missing', { read: true })
} catch (e) {
if (e instanceof FsError && e.code === 'ENOENT') {
// handle missing file
}
}
// Catching by code (when you don't care about domain)
try {
await someOp()
} catch (e) {
if (e.code === 'EPERM') { ... }
}

Propagation

Layer 1: Fileserver → Kernel

Fileservers throw FsError. The kernel does NOT catch or transform these — they propagate directly through the kernel’s mediation layer to the calling process.

proc.fs.open("/missing", { read: true })
→ kernel.open(pid, "/missing", { read: true })
→ namespace resolve → server = MemoryFS, path = "missing"
→ MemoryFS.open("missing", { read: true })
→ throws FsError('ENOENT', 'missing: no such file')
→ exception propagates up through kernel
→ proc.fs.open() rejects with FsError

The kernel is transparent to errors. It doesn’t add context, doesn’t wrap, doesn’t retry. The process sees the exact error the fileserver threw.

Decision: kernel is transparent to fileserver errors. Wrapping errors at the kernel layer would add noise without information. The process needs to know ENOENT happened — it doesn’t need to know the kernel was involved. If a fileserver throws something that’s not an FsError (a bug), it propagates as-is. We don’t mask bugs.

Layer 2: Bin → Shell → Exit Code

A bin can fail in two ways:

1. Returns a non-zero exit code (graceful):

async function grep(proc: ProcContext): Promise<number> {
// no matches found
return 1
}

The shell stores the exit code in $?. No error thrown, no stack trace.

2. Throws an uncaught exception (crash):

async function mybin(proc: ProcContext): Promise<number> {
const data = await proc.fs.readFile('/missing') // throws ENOENT
// never reaches here
return 0
}

When a bin’s promise rejects:

1. Kernel catches the rejection
2. Write error message to process's stderr:
"mybin: ENOENT: /missing: no such file"
3. Set exit code to 1 (generic failure)
4. Transition to zombie state
5. Shell sees exit code 1 in $?

Decision: uncaught exceptions in bins → exit code 1, error to stderr. The kernel acts as a last-resort catch. It doesn’t crash the system — it converts the exception into the Unix convention: error message on stderr, non-zero exit. This matches what happens when a C program segfaults — the process dies, the shell continues. The specific exit code is 1 (generic error), not a mapped errno, because the bin failed to handle its own errors. Bins that want specific exit codes should catch and return explicitly.

Layer 3: Pipeline Error Propagation

When a stage in a pipeline fails, what happens to the other stages?

Terminal window
a | b | c
# 'b' crashes — what happens to 'a' and 'c'?
1. 'b' dies → kernel closes b's fds
2. b's stdin (pipe from a) → read end closes
→ a's next write gets EPIPE
→ a receives SIGPIPE → default handler kills a (exit 141)
3. b's stdout (pipe to c) → write end closes
→ c's next read gets EOF
→ c processes remaining buffered data, then exits normally
4. Pipeline exit code = c's exit code (last command wins)

The cascade is natural: EPIPE kills upstream, EOF drains downstream. No special pipeline error handling needed — pipes and signals do the right thing.

Decision: no special pipeline error recovery. The Unix pipe/signal mechanics handle it cleanly. A dead stage propagates naturally: upstream gets EPIPE, downstream gets EOF. The shell doesn’t need to orchestrate cleanup — each process responds to its own I/O conditions. This is one of the elegant parts of Unix we should preserve exactly.

Shell Error Handling

$? (Exit Code)

Every command sets $? to its exit code. Available to the next command.

Terminal window
grep pattern file.txt
echo $? # 0 if found, 1 if not found, 1 if file missing (bin crashed)

&& and || (Logical Operators)

Terminal window
compile && test # test runs only if compile succeeds (exit 0)
compile || echo "failed" # echo runs only if compile fails (exit != 0)

set -e (Exit on Error)

Decision: support set -e but not in MVP. set -e causes the shell to exit when any command returns non-zero. It’s useful but has notoriously tricky edge cases (commands in conditions, pipelines, subshells). Defer until the shell interpreter is solid. For MVP, the shell always continues after a failed command, storing the exit code in $?.

trap (Signal Handlers)

Decision: defer trap to post-MVP. Shell-level signal trapping (trap 'cleanup' EXIT) requires the shell to register handlers with the kernel. The kernel signal mechanism supports this, but implementing trap in the shell interpreter adds complexity. For MVP, signals use default behavior (kill the process). Bins can register handlers via proc.on() — that’s sufficient.

Bin-Level Error Handling

Bins are TypeScript async functions. They use standard try/catch:

async function cat(proc: ProcContext): Promise<number> {
for (const path of proc.argv.slice(1)) {
try {
const fd = await proc.fs.open(path, { read: true })
// read and write...
await proc.fs.close(fd)
} catch (e) {
if (e instanceof FsError && e.code === 'ENOENT') {
await proc.stderr.write(`cat: ${path}: No such file or directory\n`)
return 1
}
throw e // unexpected error → crash, kernel catches
}
}
return 0
}

Decision: bins use try/catch, not result types. Async functions + exceptions is the natural TypeScript error model. Result types ({ ok, err }) are elegant but fight the language — every await becomes unwrap/match boilerplate, and integration with standard library code (TextDecoder, etc.) requires boundary translation. Exceptions propagate automatically through async boundaries, which is exactly what we want for the common case (bin doesn’t handle error → crash → stderr → exit 1).

Exit Code Conventions

CodeMeaning
0Success
1General error (also: uncaught exception default)
2Misuse of command (bad args, usage error)
126Command found but not executable
127Command not found
128 + NKilled by signal N (e.g., 141 = SIGPIPE, 143 = SIGTERM)

Decision: follow bash exit code conventions. LLMs already know these. No reason to invent a new scheme. The kernel enforces signal exit codes (128 + N) automatically. Bins return 0/1/2 by convention. The shell uses 126/127 for resolution failures.

Summary: Error Flow

Fileserver throws FsError
├─ Bin catches → handles, returns exit code
└─ Bin doesn't catch → promise rejects
└─ Kernel catches rejection
├─ Writes error to stderr
├─ Sets exit code = 1
└─ Process becomes zombie
└─ Shell collects exit code via wait()
├─ Stores in $?
├─ && / || check it
└─ set -e would exit (future)

No error is ever silently swallowed. Every failure either becomes an exit code (handled) or a stderr message + exit code 1 (unhandled). The system always continues — one bin crashing doesn’t bring down the shell or the kernel.