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
stoppedstate 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 extendingProcessState.
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
kindfield. The kernel dispatches onfd.kindto route to the correct backend — fileserver methods for'server', pipe read/write logic for'pipe'. This eliminates thenullcheck 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:
- Find the mount point with the longest prefix that matches the path
- Strip the prefix from the path
- 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
Mapcopied 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 theObject.keys()problem — prototype-chainedRecordobjects don’t enumerate inherited keys, which would break namespace resolution. AMapwith 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 = 129Bins 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/shecho helloThe 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/pythonWhy filesystem-based? This follows Plan 9’s philosophy: configuration is files. Any process can register an interpreter without a special API:
echo '/bin/python' > /lib/interp/pyPrecedence: 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 neededconst name = proc.argv[2] || 'world'await proc.stdout.write(`Hello, ${name}!\n`)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 cwd2. Namespace lookup → { server, remainder }3. Call server.open(remainder, mode) → innerFd4. Allocate lowest-available process fd5. Store Fd { server, innerFd, mode, offset: 0 }6. Return process fdread(fd, count)
1. Look up fd in process fd table → { server, innerFd, offset }2. Check mode allows reading3. Call server.read(innerFd, offset, count) → data4. Advance offset by data.length5. Return datawrite(fd, data)
1. Look up fd in process fd table → { server, innerFd, offset }2. Check mode allows writing3. Call server.write(innerFd, offset, data) → bytesWritten4. Advance offset by bytesWritten5. Return bytesWrittenclose(fd)
1. Look up fd in process fd table → { server, innerFd }2. Call server.close(innerFd)3. Remove fd from process fd table4. Fd number becomes available for reusestat(path) / readdir(path)
1. Normalize and resolve path against cwd2. 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 handlesExecSentinel. If a bin’s owntry/catchswallows the sentinel,exec()will silently fail. Bins that need error handling should catch specific error types (e.g.,FsError) rather than barecatch(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 abandonedKernel 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.offsetDecision:
whenceis a string enum, not a numeric constant.'set' | 'current' | 'end'is self-documenting. C’sSEEK_SET = 0is 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 numberThis 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'throwEINVALuntil 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.allSettledis required, notPromise.all. SIGKILL causes a process’s promise to reject.Promise.allwould short-circuit on the first rejection, leaving other process promises unsettled and the log undisposed.Promise.allSettledwaits 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 exitKnown 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.
cat /dev/user # → rootecho agent > /dev/userwhoami # → agentThis 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): FileserverThe 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/userhas mutable state and write semantics;/dev/timeis stateless (Date.now()is a pure function call). The structural difference justifies separate factories. This keepsdevFSstateless 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/userbecomes a read-only projection synthesized fromproc.uidin procFS. Writing to/dev/useris replaced by an authenticatedsetuid()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/timeis 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 linectl 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 pipe1 w pipe2 w pipe3 rw serverFields: 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:
cat /proc/1/ctl # → runningecho kill > /proc/1/ctl # → sends SIGKILLecho term > /proc/1/ctl # → sends SIGTERMecho hup > /proc/1/ctl # → sends SIGHUPThe 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:
termnotstop.stopmeans SIGSTOP (freeze) in both Plan 9 and Unix. Usingstopfor graceful termination would create a naming collision that confuses anyone with Unix background.termis 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 | CtlFdprocFS.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:
CtlFdas a discriminated union variant, not a flag on ProcFd. A flag approach (writable: boolean) would still require a runtime check inwrite()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,): FileserverIf 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.