Skip to content

Kernel

The only privileged code. Routes I/O, manages processes, knows nothing about fileserver implementations.

Process Table

The kernel maintains a Map<number, Process> keyed by pid.

type ProcessState = 'running' | 'zombie'
interface Process {
pid: number // unique, monotonically increasing
ppid: number // parent's pid (pid 1 has ppid 0)
uid: number // user identity (default 0, set by login via setuid)
sid: number // session id (pid of session leader, default = own pid)
pgid: number // process group id (default = own pid, future: job control)
state: ProcessState
namespace: Namespace // shallow-copied from parent on spawn
fds: Map<number, Fd> // process-local fd number → open descriptor
env: Record<string, string> // shallow-copied from parent on spawn
cwd: string
argv: string[]
exitCode: number | null // set on exit, null while running
bin: (proc: ProcContext) => Promise<number>
promise: Promise<number> // the running bin's promise
waiters: Array<(code: number) => void> // resolve callbacks for wait()
}

Pid allocation is a monotonically increasing counter. Never reused within a session. Pid 1 is the init/shell process, ppid 0 means “no parent” (kernel-spawned).

Decision: no stopped state in MVP. Processes are either running or zombie. Stopped (SIGTSTP/SIGSTOP/SIGCONT) adds job control complexity with no benefit for LLM agents, who don’t need to background and foreground tasks interactively. Can be added later by extending ProcessState.

File Descriptors

Allocation

Lowest-available integer, starting from 0. Every process starts with fds 0, 1, 2 pre-allocated (stdin, stdout, stderr). This is load-bearing — shell redirects like 2>&1 depend on specific fd numbers.

Fd Table Entry

type Fd = FileserverFd | PipeFd
interface FileserverFd {
kind: 'server'
server: Fileserver
innerFd: unknown // opaque token from server.open()
mode: 'r' | 'w' | 'rw'
offset: number // current read/write position
}
interface PipeFd {
kind: 'pipe'
pipe: PipeState
mode: 'r' | 'w' // pipe ends are unidirectional
}

Decision: Fd is a discriminated union with a kind field. The kernel dispatches on fd.kind to route to the correct backend — fileserver methods for 'server', pipe read/write logic for 'pipe'. This eliminates the null check on every I/O call and gives TypeScript proper narrowing.

Two-level fd scheme: the kernel allocates process-local fd numbers, the fileserver allocates its own internal token via open(). The kernel maps between the two. This means a fileserver never sees process-level fd numbers and processes never see fileserver-internal tokens.

Inheritance on Spawn

Decision: dup semantics (independent offsets). When a child inherits a parent’s fd, it gets a new Fd entry pointing to the same server and innerFd but with an independent offset. This prevents parent and child from stepping on each other’s file positions. Shared-offset semantics are simpler but create subtle bugs when both processes read from the same fd. If shared offsets are ever needed, they can be opted into explicitly.

Namespace Resolution

Algorithm: Longest Prefix Match

Given a set of mounts and a path:

  1. Find the mount point with the longest prefix that matches the path
  2. Strip the prefix from the path
  3. Pass the remainder to the fileserver
mounts: { '/': MemoryFS, '/home': PersistentFS, '/proc': ProcFS }
resolve("/home/user/file.txt")
→ mount="/home", server=PersistentFS, remainder="user/file.txt"
resolve("/proc/1/status")
→ mount="/proc", server=ProcFS, remainder="1/status"
resolve("/tmp/scratch")
→ mount="/", server=MemoryFS, remainder="tmp/scratch"

Namespace Structure

interface Namespace {
mounts: Map<string, Fileserver> // path prefix → server
resolve(path: string): { server: Fileserver, path: string }
}

Decision: shallow copy on spawn, not prototype chaining. On spawn, the child gets a new Map copied from the parent’s mounts. The child can add/remove mounts without affecting the parent, and the parent’s future mounts are NOT visible to the child (snapshot semantics). This is simpler and avoids the Object.keys() problem — prototype-chained Record objects don’t enumerate inherited keys, which would break namespace resolution. A Map with shallow copy is explicit and correct.

Performance

The sorted mount list (by prefix length, descending) is cached and invalidated only on mount() / bind(). This is a hot path — every open, stat, readdir goes through it.

Spawn

Step-by-step, what happens when a process calls spawn(bin, argv, opts):

1. Allocate next pid (increment counter)
2. Create process entry:
pid = nextPid++
ppid = caller's pid
state = 'running'
namespace = new Map(parent.namespace.mounts) // shallow copy
env = { ...parent.env } // shallow copy
cwd = opts.cwd ?? parent.cwd
argv = argv
exitCode = null
3. Set up file descriptors:
If opts.stdin/stdout/stderr provided → use those
Otherwise → dup parent's fds 0/1/2 (same server+innerFd, fresh offset)
Additional fds from opts.fds if provided
4. Resolve bin:
If bin is a function → use directly
If bin is a string path → open via namespace, read contents, evaluate as module
5. Build ProcContext (the API surface the bin sees)
6. Start execution:
proc.promise = bin(procContext)
proc.promise.then(code => {
proc.exitCode = code ?? 0
proc.state = 'zombie'
close all fds
notify all waiters
if parent is zombie → reap this process immediately
})
7. Return handle: { pid, stdin, stdout, stderr, wait() }

Wait and Reaping

wait(pid) returns a promise that resolves with the exit code:

  • If process is already a zombie → resolve immediately, reap (remove from table)
  • If process is running → add to waiters, resolve when it exits
  • Orphaned zombies (parent already exited) are reaped immediately — no zombie accumulation

Decision: no init-style reaping process. Orphan zombies are reaped eagerly by the kernel when the parent exits. This avoids needing a dedicated reaper and keeps the process table clean. Real Unix needs init to reparent orphans; we don’t because we control the lifecycle directly.

Signals

MVP Signals

Decision: SIGTERM, SIGKILL, SIGPIPE, and SIGHUP for MVP. These cover the essential cases: graceful shutdown, forced kill, broken pipe, and terminal hangup. SIGHUP is needed for session cleanup when a controlling terminal disconnects (see users-and-sessions spec). Job control signals (SIGTSTP, SIGCONT) deferred since there’s no stopped state. SIGINT can alias to SIGTERM. Others added as needed.

Delivery Mechanism

kernel.signal(pid, sig):
1. Look up process in table. If not found → ESRCH error.
2. SIGKILL:
- Unconditional. No handler can intercept.
- Reject the process's promise.
- Set exitCode = 137, state = 'zombie'.
- Close all fds, notify waiters.
3. All other signals:
- If process has a registered handler → call it
- Otherwise apply default action:
- SIGTERM → reject promise, exitCode = 143
- SIGPIPE → reject promise, exitCode = 141
- SIGHUP → reject promise, exitCode = 129

Bins register signal handlers via:

proc.on('SIGTERM', async () => {
// cleanup
proc.exit(0)
})

Handlers are async-safe — they run as microtasks, not interrupts. A handler that doesn’t call proc.exit() effectively catches and ignores the signal (except SIGKILL, which can’t be caught).

Executable Dispatch

When the kernel resolves a command name to a function, it follows a chain of fallbacks. This happens in resolveBin() (src/kernel/index.ts).

Resolution Order

1. Function — bin is already a BinFunction → use directly
2. Path resolution — bin is a string
If absolute (/bin/foo) or relative (./foo) → use as-is
If bare name (foo) → search $PATH directories
3. For each candidate path:
a. stat() — must exist and not be a directory
b. Check executable bit — mode & 0o111 must be set
c. ExecCapable — if the fileserver implements getExec(), try it
d. Shebang — read first 256 bytes, look for #! header
e. Extension dispatch — extract file extension, check /lib/interp/<ext>

Steps (c), (d), and (e) are tried in order. If a step succeeds, the remaining steps are skipped.

Shebang Dispatch

Standard Unix behavior. If a file starts with #!:

#!/bin/sh
echo hello

The kernel parses the interpreter path (and optional single argument) from the first line, then recurses through resolveBin() with the interpreter as the new target. The script path is prepended to argv:

argv: ["sh", "/tmp/script.sh", "arg1", "arg2"]

#!/usr/bin/env <cmd> is handled specially — <cmd> is resolved through $PATH.

Recursion is capped at SHEBANG_MAX_DEPTH = 4 to prevent infinite loops (e.g., script A shebangs to script B which shebangs back to A).

Extension-Based Interpreter Dispatch

A fishbowl convention (not standard Unix). When a file has an executable bit but no shebang and no native exec registration, the kernel extracts the file extension and checks for a registered interpreter.

How it works:

File: /tmp/fizzbuzz.js (executable, no shebang)
1. Kernel extracts extension: "js"
2. Kernel reads /lib/interp/js → contains "/bin/js"
3. Kernel resolves /bin/js via resolveBin (recursion depth + 1)
4. Kernel wraps: argv = ["/bin/js", "/tmp/fizzbuzz.js", ...original args]

The association file at /lib/interp/<ext> is a plain text file whose content is the absolute path to the interpreter binary. One line, no arguments, no JSON — just a path.

$ cat /lib/interp/js
/bin/js
$ cat /lib/interp/py
/bin/python

Why filesystem-based? This follows Plan 9’s philosophy: configuration is files. Any process can register an interpreter without a special API:

Terminal window
echo '/bin/python' > /lib/interp/py

Precedence: Shebangs always win. If a .js file has #!/bin/node at the top, the shebang is used, not /lib/interp/js. Extension dispatch is strictly a fallback.

Package integration: Packages register interpreters by including the association as a regular file in their manifest. No special manifest field is needed:

{
name: 'python',
files: {
'/lib/interp/py': '/bin/python', // registers .py → /bin/python
}
}

On pkg remove, the file is cleaned up automatically (tracked in the install record like any other file).

Recursion limit: Extension dispatch shares the same SHEBANG_MAX_DEPTH = 4 counter as shebang dispatch. A chain like .js/bin/js (which is itself a .sh script with a shebang) works fine as long as the total depth stays under 4.

The js Interpreter

fishbowl ships with a built-in JavaScript interpreter at /bin/js. The standard system preset (stdSystem()) registers the association /lib/interp/js → /bin/js so .js files are executable out of the box.

Scripts run with proc in scope — the same ProcContext available to all bins:

// /tmp/hello.js — no shebang needed
const name = proc.argv[2] || 'world'
await proc.stdout.write(`Hello, ${name}!\n`)
Terminal window
chmod +x /tmp/hello.js
/tmp/hello.js Alice # → Hello, Alice!

The interpreter uses AsyncFunction to evaluate the script, so await works at the top level. Scripts can access proc.argv, proc.stdout, proc.stderr, proc.env, proc.fs, and all other ProcContext methods. A numeric return value sets the exit code (default 0).

Kernel ↔ Fileserver Mediation

Every I/O syscall goes through the kernel. The kernel never inspects data, never interprets paths beyond mount resolution. It translates process-local fds to fileserver calls:

open(path, mode)

1. Normalize and resolve path against cwd
2. Namespace lookup → { server, remainder }
3. Call server.open(remainder, mode) → innerFd
4. Allocate lowest-available process fd
5. Store Fd { server, innerFd, mode, offset: 0 }
6. Return process fd

read(fd, count)

1. Look up fd in process fd table → { server, innerFd, offset }
2. Check mode allows reading
3. Call server.read(innerFd, offset, count) → data
4. Advance offset by data.length
5. Return data

write(fd, data)

1. Look up fd in process fd table → { server, innerFd, offset }
2. Check mode allows writing
3. Call server.write(innerFd, offset, data) → bytesWritten
4. Advance offset by bytesWritten
5. Return bytesWritten

close(fd)

1. Look up fd in process fd table → { server, innerFd }
2. Call server.close(innerFd)
3. Remove fd from process fd table
4. Fd number becomes available for reuse

stat(path) / readdir(path)

1. Normalize and resolve path against cwd
2. Namespace lookup → { server, remainder }
3. Call server.stat(remainder) or server.readdir(remainder)
4. Return result directly (no fd involved)

Kernel API Surface

The kernel exposes these operations. Processes access them through the ProcContext wrapper, never directly:

interface Kernel {
// Process management
spawn(bin, argv, opts): Promise<ChildHandle>
wait(pid): Promise<ExitCode>
waitAny(ppid): Promise<{ pid: Pid, code: ExitCode }>
signal(pid, sig): void
exit(pid, code): void
exec(pid, bin, argv): void // replace process image (see exec semantics below)
// Filesystem (routed through namespace)
open(pid, path, flags): Promise<FdNumber>
read(pid, fd, count): Promise<Uint8Array>
write(pid, fd, data): Promise<number>
close(pid, fd): Promise<void>
stat(pid, path): Promise<Stat>
readdir(pid, path): Promise<DirEntry[]>
// Mutation (routed through namespace)
mkdir(pid, path): Promise<void>
remove(pid, path): Promise<void>
rename(pid, oldPath, newPath): Promise<void>
// File descriptor manipulation
seek(pid, fd, offset, whence): number // reposition fd offset
dup(pid, srcFd, dstFd?): FdNumber // duplicate fd
// Namespace
mount(pid, server, path): void
bind(pid, oldPath, newPath, flags?): void // flags: 'before' | 'after' | 'replace'
// Pipes
pipe(): [Fd, Fd]
// Identity & sessions (see users-and-sessions spec)
setuid(pid, uid): void // restricted to uid=0
setsid(pid): Pid
getsid(pid): Pid
setpgid(pid, pgid): void
getpgid(pid): Pid
}

exec Semantics

exec replaces a process’s bin and argv while keeping the same pid, fds, namespace, and env. Used by the login → shell transition.

Implementation: proc.exec(bin, argv) throws an ExecSentinel error. The kernel’s bin-runner wrapper is the only try/catch around bin execution, and it catches ExecSentinel specifically. Code after exec() in the calling bin never runs.

Constraint: bins must NOT catch ExecSentinel. The kernel wraps every bin invocation in a single try/catch that handles ExecSentinel. If a bin’s own try/catch swallows the sentinel, exec() will silently fail. Bins that need error handling should catch specific error types (e.g., FsError) rather than bare catch(e), or re-throw unknown errors. This is a documented contract, not a runtime guard — we trust bin authors (including LLMs) to follow it.

proc.exec('/bin/sh', []):
1. Throw ExecSentinel { bin: '/bin/sh', argv: [] }
2. Kernel's bin-runner catch intercepts ExecSentinel
3. Resolve new bin (if string path)
4. Replace process.bin and process.argv
5. Start new bin: process.promise = newBin(procContext)
6. Old bin's execution context is abandoned
Kernel bin-runner wrapper (the only try/catch around bin execution):
try {
exitCode = await bin(procContext)
} catch (e) {
if (e instanceof ExecSentinel) → replace process image, restart
else → exitCode = 1, write e.message to stderr
}

Every filesystem method takes pid as the first argument so the kernel knows which process’s namespace and fd table to use.

Seek

Repositions a file descriptor’s current offset. Only valid for FileserverFd entries (not pipes — pipes are sequential by nature).

kernel.seek(pid, fd, offset, whence):
1. Look up fd in process fd table
2. If fd.kind === 'pipe' → throw EINVAL (pipes are not seekable)
3. Apply whence:
'set' → fd.offset = offset
'current' → fd.offset += offset
'end' → fd.offset = stat(fd).size + offset
4. Return new fd.offset

Decision: whence is a string enum, not a numeric constant. 'set' | 'current' | 'end' is self-documenting. C’s SEEK_SET = 0 is hostile to TypeScript. The kernel translates to the correct offset arithmetic.

Most bins won’t use seek — stdin/stdout are sequential streams. Seek exists for bins that need random access (e.g., dd, database tools, binary file inspection). Without it, the only way to re-read a file is to close and reopen it.

Dup

Duplicates a file descriptor. The new fd points to the same backing (same server + innerFd or same pipe) with an independent offset.

kernel.dup(pid, srcFd, dstFd?):
1. Look up srcFd in process fd table
2. If dstFd provided:
- If dstFd is open → close it first
- Assign the duplicate to dstFd (dup2 semantics)
3. If dstFd not provided:
- Allocate lowest-available fd number (dup semantics)
4. Create new Fd entry with same server/innerFd/pipe, fresh offset
5. Return the new fd number

This is required for shell redirect handling. 2>&1 means “make fd 2 point to whatever fd 1 points to” — that’s dup(pid, 1, 2). Without dup as a kernel operation, the shell would need to reach into the fd table directly, violating the kernel boundary.

Bind Flags

bind() maps one namespace path to another, supporting three modes for eventual union directory support:

kernel.bind(pid, oldPath, newPath, flags = 'replace'):
flags:
'replace' — newPath replaces oldPath in the namespace (default, MVP)
'before' — newPath is searched before oldPath (union mount, prepend)
'after' — newPath is searched after oldPath (union mount, append)

Decision: MVP implements 'replace' only. Union mounts ('before' / 'after') require the namespace to support multiple fileservers at a single mount point with ordered fallback resolution. The flag is in the API from day one so the signature doesn’t change, but 'before' and 'after' throw EINVAL until union mount support is implemented.

Shutdown

UnixInstance.shutdown() tears down a running system cleanly. It is async because the shutdown sequence involves waiting for processes to exit.

interface UnixInstance {
shutdown(): Promise<void>
[Symbol.asyncDispose](): Promise<void>
}

Shutdown Sequence

1. Snapshot running pids from the process table (filter proc.state === 'running')
2. SIGTERM all running processes
— makeInit registers a SIGTERM handler that runs orderly service teardown
— SIGKILL-only would bypass this, leaving services in undefined state
3. Wait up to 5 seconds for graceful exit
— Promise.race([Promise.allSettled(runningPromises), timeout(5000)])
4. SIGKILL any processes still in 'running' state after the timeout
5. Promise.allSettled([...processTable.values()].map(p => p.promise))
— allSettled, not Promise.all: SIGKILL rejects process promises;
Promise.all would throw and skip remaining cleanup steps
6. kernelLog.dispose()
— wakes any blocked /proc/log readers (e.g. follow-mode dmesg)

Decision: Promise.allSettled is required, not Promise.all. SIGKILL causes a process’s promise to reject. Promise.all would short-circuit on the first rejection, leaving other process promises unsettled and the log undisposed. Promise.allSettled waits for every promise regardless of outcome.

Idempotency

A _disposed boolean flag in the boot() closure guards against double-dispose. The second call returns immediately without signaling or logging. This is important for await using patterns where cleanup may be triggered both explicitly and by scope exit.

Explicit-Only — wait() Does Not Auto-Trigger

wait() returns the exit code of the root process (PID 1). It deliberately does NOT call shutdown(). Auto-triggering shutdown from wait() would break test utilities that inspect the kernel log after the shell exits — the log must remain readable until the caller chooses to dispose.

The ergonomic path for automatic cleanup is await using:

await using system = await Unix().use(stdSystem()).boot()
// [Symbol.asyncDispose] calls shutdown() automatically on scope exit

Known Limitation

handleExit fires fd.server.close(fd.innerFd) as void (fire-and-forget). Open fd cleanup during process exit is not awaited. For all current fileservers this is benign — closes are synchronous-equivalent. Future fileservers with genuinely async close semantics should await close promises. Tracked as follow-on work.

User Identity — /dev/user

User identity is exposed as a file: /dev/user. Reading it returns the current username. Writing to it changes the stored username.

Terminal window
cat /dev/user # → root
echo agent > /dev/user
whoami # → agent

This is the “everything is a file” principle applied to identity. Identity manipulation is composable with any tool that reads or writes files — pipelines, redirects, tee, etc.

userFS Factory

userFS(initialUser?) is a standalone single-file fileserver. The mount point IS the file — the inner path is '' (the empty string). Any non-empty inner path throws ENOENT.

export function userFS(initialUser?: string): Fileserver

The choice to make it standalone (not part of devFS) mirrors the /dev/tty precedent: devFS is intentionally stateless — all its devices (null, zero, random, time) are pure functions of their inputs with no constructor arguments. userFS holds mutable state (the username variable in its closure) and requires an initial value, making it structurally different.

Decision: standalone factory, not a devFS extension. /dev/user has mutable state and write semantics; /dev/time is stateless (Date.now() is a pure function call). The structural difference justifies separate factories. This keeps devFS stateless and consistent.

Per-Boot Instantiation

userFS('root') is called inside boot() in builder.ts, not at module scope. Each call to image.boot() creates a fresh closure with its own username variable. Two concurrent boots of the same image have independent /dev/user state.

Phase A: Shared Instance

In Phase A, /dev/user is a single shared instance per boot. All processes in a boot share the same fileserver; a write by any process is immediately visible to all other processes. This is documented in source but is a deliberate MVP trade-off — Process.uid is always 0 in the current kernel, so per-process identity enforcement has no meaningful implementation target yet.

Phase B plan: /dev/user becomes a read-only projection synthesized from proc.uid in procFS. Writing to /dev/user is replaced by an authenticated setuid() kernel call. The writable-device authorization gap is eliminated by removing the writable device entirely.

/dev/time

/dev/time is in devFS alongside null, zero, and random. It returns the current Unix timestamp in milliseconds as a decimal string on each read (not snapshot-at-open — each read() call invokes Date.now()). Writes return EPERM.

Decision: EPERM on write, not silent discard. /dev/time is a sensor, not a sink. Discarding writes silently (like /dev/null) would be a false analogy that hides bugs. EPERM propagates through shell redirects as a non-zero exit, giving immediate diagnostic feedback.

Note: the timestamp is milliseconds since epoch (consistent with Stat.mtime), not Plan 9’s seconds-since-epoch format. This divergence is documented in source.

whoami Integration

The whoami bin reads /dev/user. It falls back to proc.env['USER'] only on ENOENT (the device is not mounted — minimal kernel context). Any other error from /dev/user propagates as-is rather than being swallowed.

DEFAULT_ENV includes USER: 'root' as a convenience sync with the /dev/user initial value, so env-based queries and file-based queries agree without extra configuration.

Process Filesystem — /proc Skeleton

/proc/<pid>/ exposes process state as readable files and provides process control through a writable interface. This makes the observe-understand-act loop over a running process possible entirely through the filesystem.

Files Per PID

/proc/<pid>/
status — pid, ppid, state, uid, cwd (original; preserved)
env — environment variables, KEY=VALUE\n per line
argv — command arguments, one per line
cwd — current working directory path followed by \n
fd — open file descriptors, one per line: <num> <mode> <type>
ctl — readable+writable process control interface
ns — mount point paths, one per line

ctl and ns only appear in readdir and are only openable when the corresponding callbacks are wired. See Callback-Based Wiring below.

fd File Format

0 r pipe
1 w pipe
2 w pipe
3 rw server

Fields: fd number (ascending sort), mode (r/w/rw), type (pipe/server). The fd path — which filesystem path the descriptor points to — requires kernel tracking of the open path at open() time. This is deferred to Phase 2.

ctl — Process Control Through the Filesystem

ctl is a read-write file. Reading returns the process state (running\n or zombie\n). Writing sends a control command:

Terminal window
cat /proc/1/ctl # → running
echo kill > /proc/1/ctl # → sends SIGKILL
echo term > /proc/1/ctl # → sends SIGTERM
echo hup > /proc/1/ctl # → sends SIGHUP

The command vocabulary is owned by procFS:

const CTL_COMMANDS: Record<string, Signal> = {
kill: 'SIGKILL',
term: 'SIGTERM', // NOT 'stop' — "stop" means freeze/pause (SIGSTOP) in Plan 9 and Unix
hup: 'SIGHUP',
int: 'SIGINT',
}

Decision: term not stop. stop means SIGSTOP (freeze) in both Plan 9 and Unix. Using stop for graceful termination would create a naming collision that confuses anyone with Unix background. term is unambiguous.

Unknown commands return EINVAL. Accessing ctl for a pid that has exited maps ESRCH from the signal callback to ENOENT at the procFS boundary — consistent with the Plan 9 convention that a missing process entry is simply “not found.”

CtlFd Discriminant

ctl opens with a distinct CtlFd token in the internal AnyProcFd discriminated union:

interface CtlFd {
kind: 'ctl'
pid: Pid
content: string // state text, snapshot-at-open
}
type AnyProcFd = ProcFd | LogFd | CtlFd

procFS.write() checks fd.kind === 'ctl' before accepting a write. Every other fd kind returns EACCES. This is a type-system-enforced invariant, not a runtime string check.

Decision: CtlFd as a discriminated union variant, not a flag on ProcFd. A flag approach (writable: boolean) would still require a runtime check in write() with no compiler help. The discriminated union gives TypeScript exhaustive narrowing and makes the “only ctl is writable” invariant visible in the type definition.

Snapshot-at-Open Semantics

env, argv, cwd, and fd content is computed once at open() time and stored in the fd record. Re-reading from the same open fd returns the same snapshot. Close and reopen to get a fresh snapshot.

This matches the existing /proc/log pattern and avoids the complexity of live state synchronization across async reads. The contract: the file reflects the process state at the moment it was opened.

ctl read (process state) is also snapshot-at-open, matching the same pattern.

Callback-Based Wiring

procFS() accepts optional callbacks for capabilities that require kernel access:

export function procFS(
table: Map<Pid, Process>,
log?: KernelLog,
signal?: (pid: Pid, sig: Signal) => void,
getNs?: (pid: Pid) => string,
): Fileserver

If signal is not provided: ctl does not appear in readdir; opening /<pid>/ctl throws ENOENT. If getNs is not provided: ns does not appear in readdir; opening /<pid>/ns throws ENOENT.

Decision: narrow callbacks, not a kernel reference. Passing the kernel object into procFS would give it access to every kernel operation — far broader than needed. Narrow callbacks document the exact contract: procFS needs one function to send a signal and one function to serialize a namespace. This also keeps procFS testable in isolation, without a full kernel.

The callbacks are wired in builder.ts at boot() time:

procFS(
processTable,
kernelLog,
(pid, sig) => kernel.signal(pid, sig),
(pid) => {
const proc = processTable.get(pid)
if (proc === undefined) return ''
return [...proc.namespace.mounts.keys()].sort().join('\n') + '\n'
},
)

The getNs callback closes over processTable directly and serializes mount keys as sorted paths. It never exposes Namespace or Fileserver references through the procFS interface.

parsePath() Refactoring

The internal parsePath() function was refactored to handle non-PID virtual paths alongside PID paths:

function parsePath(path: string): {
virtual: string | null // non-PID path (e.g. 'log')
pid: Pid | null
file: string | null
}

virtual is set for well-known non-PID paths (currently only 'log'). The caller checks virtual first, before attempting PID parsing. This avoids parseInt('log') returning NaN and throwing a confusing ENOENT.