Logging
How the system observes itself. Two independent mechanisms — kernel logging for system events, shell tracing for command execution — connected by clear boundaries.
Design Decisions
Why not console?
Decision: custom scoped logger, not
console.consoleis a host concept — it writes to the Node terminal or browser devtools. The kernel ring buffer is a system concept — it lives inside fishbowl. If we hijackconsole, we lose the ability to debug the TypeScript implementation itself.console.log("why is this broken")during development must reach the developer’s terminal, not the in-system ring buffer. Two audiences, two channels:consolefor host developers debugging TypeScript,klogfor the kernel recording system events.
Why not an external logging library?
Decision: no dependency on pino, winston, bunyan, etc. JS logging libraries solve problems we don’t have: log rotation, file transports, pretty-printing, worker threads for async I/O, serializers for HTTP objects. They’re designed for server applications, not OS kernels. Our logging surface is ~100 lines of code: one ring buffer class, one scoped helper, ~15-20 call sites. An adapter to pino would be more code than the implementation. The consumers are
cat,grep, anddmesg— not Datadog. Zero dependencies is a feature of this project.
Why a data structure, not a fileserver?
Decision: the ring buffer is a plain TypeScript class, not a fileserver. If the ring buffer were a mounted fileserver, the kernel would need to write to it through its own I/O path — but the kernel manages that I/O path. Circular dependency. Instead, the kernel writes to the ring buffer directly (synchronous, in-memory). A separate read-only view in procFS provides the
/proc/loginterface for guest access. This mirrors Linux:printk()writes to a memory buffer;/proc/kmsganddmesgare views onto it.
Why /proc/log, not /dev/klog?
Decision: extend procFS rather than adding a new fileserver. procFS already synthesizes read-only content from kernel state (
/proc/<pid>/status,/proc/<pid>/fd/). A kernel log is the same pattern — read-only, generated from an internal data structure. Adding it to procFS avoids a new fileserver, a new mount point, and a new preset. It fits the existing architecture exactly.
Why syslog encoding?
Decision: use standard syslog priority encoding (RFC 5424). The
prifield encodes facility and severity asfacility * 8 + severity, recoverable viaseverity = pri & 0x7,facility = pri >> 3. This is the universal Unix convention. It makes the output grep-able with standard tools, forward-compatible with a future syslog service, and immediately recognizable to anyone who has read/var/log/syslog. Numeric severity (0-7) is more composable than string enums —awk '$sev <= 3'filters warnings and above.
Why separate shell tracing from kernel logging?
Decision:
set -xwrites to stderr, not the ring buffer. Shell tracing is per-shell, high-volume, user-facing output. It follows the data flow the user configured (redirects, pipes). Mixing it into the kernel log would pollute system diagnostics with shell-level noise. This matches bash, zsh, and POSIX sh behavior. Two independent mechanisms for two different concerns.
What about WASM programs?
Decision: WASM programs don’t touch the logging system. WASM bins already have their diagnostic path: Emscripten’s
print()routes toproc.stdout,printErr()routes toproc.stderr. They’re regular processes — they write to stderr like any bin. The kernel may log about WASM operations (module loaded, asyncify bridge initialized), but WASM code itself never writes to the ring buffer. The boundary is clean: kernel internals log to the ring buffer, processes log to stderr.
Cross-platform considerations
Decision: no platform-specific code in the logging system. The ring buffer is a plain TypeScript array with a write head. procFS is pure TypeScript. The scoped logger is a thin wrapper. The only platform-dependent piece is the clock source —
performance.now()for monotonic timing — which is available in both Node and browsers. Everything lives inside the fishbowl abstraction layer. This is a benefit of keeping it as a data structure + fileserver view rather than using an external library with Node-specific fast paths.
Architecture
Two independent mechanisms:
Kernel Logging Shell Tracing───────────────────── ─────────────────KernelLog (ring buffer) set -x / set +x ↑ write via scoped logger ↑ flag in shell state │ │ ├─ kernel call sites (~15-20) ├─ interpreter checks flag ├─ init/supervisor events (future) │ before each command ├─ pkg system events (future) │ └─ TRACE env var (opt-in debug) ↓ │ stderr (+ prefix) ↓ read-only view/proc/log (virtual file in procFS) │ ├─ cat /proc/log (snapshot) ├─ dmesg (formatted) └─ dmesg -w (follow mode — not yet implemented)Component 1: Kernel Ring Buffer
A plain TypeScript class. Not a fileserver. Not mounted. The kernel writes to it directly and synchronously.
Types
export const LogSeverity = { Emergency: 0, // system is unusable Alert: 1, // action must be taken immediately Critical: 2, // critical conditions Error: 3, // error conditions Warning: 4, // warning conditions Notice: 5, // normal but significant Info: 6, // informational Debug: 7, // debug-level messages} as constexport type LogSeverity = typeof LogSeverity[keyof typeof LogSeverity]
export const LogFacility = { Kern: 0, // kernel messages User: 1, // user-level messages Daemon: 3, // system daemons (init, services) Local0: 16, // pkg system Local1: 17, // shell Local2: 18, // wasm runtime} as constexport type LogFacility = typeof LogFacility[keyof typeof LogFacility]
interface KernelLogEntry { seq: number // monotonically increasing sequence number pri: number // facility * 8 + severity time: number // monotonic ms since boot (via performance.now()) pid: Pid | undefined // originating process, if applicable msg: string // human-readable message}Ring Buffer Behavior
- Fixed-size, configurable (default 1024 entries)
- Oldest entries evicted when full (circular overwrite)
seqassigned on write, monotonically increasing (never resets)write(facility, severity, pid, msg)— append, assign seq, notify waiting readersentries()— return all current entries (for test assertions)readFrom(cursor)— return entries with seq >= cursor (direct ring index, no intermediate copy)waitForNew()— block (Promise) until new entries arrive- Timer function cached once in constructor (
performance.now()orDate.now()fallback);time= delta from boot
Scoped Logger (Developer Experience)
The call-site API. One scoped logger per subsystem, created once:
// created once in createKernel()const klog = kernelLog.scoped(LogFacility.Kern)
// at call sites — one line, minimal noiseklog.info(pid, `started ${argv[0]}`)klog.warn(pid, `signal ${sig} unhandled`)klog.error(pid, `exec failed: ${path}`)klog.debug(pid, `open ${path} flags=${flags} -> fd:${fd}`)scoped(facility) returns an object with one method per severity level. Each method handles pri encoding, seq assignment, and timestamping internally. The caller provides only what varies: pid and message.
For subsystems outside the kernel:
// init supervisorconst dlog = kernelLog.scoped(LogFacility.Daemon)dlog.notice(pid, `restarting '${name}' (attempt ${count})`)
// package systemconst plog = kernelLog.scoped(LogFacility.Local0)plog.info(pid, `installed ${name}@${version}`)Injection
createKernel(opts?: KernelOpts) accepts a log?: KernelLog field. The KernelLog instance is created by the boot sequence (image.ts) before the kernel, passed to both procFS() and createKernel(), ensuring availability from the first kernel operation.
Call Sites
Direct calls at high-signal event points. Not every function — only events that matter for troubleshooting.
Implemented:
| Facility | Event | Severity | Data |
|---|---|---|---|
| Kern | Process spawned | Info | pid, command, argv |
| Kern | Process exited | Info | pid, exit code |
| Kern | Process killed by signal | Warning | pid, signal |
| Kern | Exec failed | Error | pid, bin name |
| Kern | Mount | Info | pid, path |
Planned (not yet implemented):
| Facility | Event | Severity | Data |
|---|---|---|---|
| Kern | Namespace resolution failure | Warning | path, reason |
| Daemon | Service started | Info | name, pid |
| Daemon | Service exited | Info/Warning | name, exit code |
| Daemon | Service restarting | Notice | name, count, backoff |
| Daemon | Service gave up | Error | name, reason |
| Local0 | Package installed | Info | name, version |
| Local0 | Package install failed | Error | name, reason |
| Local0 | Bundle evaluation failed | Error | name, error |
Component 2: /proc/log
A read-only virtual file in the existing procFS fileserver.
Read Behavior
Snapshot read: cat /proc/log returns all entries currently in the buffer as text, then EOF:
<6>[0.001] pid:1 started /bin/init<6>[0.003] mount /dev -> devFS<27>[1.200] pid:5 service 'httpd' exited with code 1Where <6> = kern.info, <27> = daemon.error.
Blocking read (follow mode): when a reader reaches the end of the buffer, read() returns a Promise that resolves when new entries arrive. Same async blocking pattern as createPipe(). Enables dmesg -w.
Current Implementation
The current implementation uses snapshot-at-open semantics: when /proc/log is opened, all current entries are rendered to text and stored on the fd. Subsequent reads return from this snapshot. This is simpler than cursor-based streaming but means new entries written after open are not visible.
The procFS parsePath() was refactored to return a { virtual, pid, file } tuple, checking for well-known non-PID paths (log) before PID parsing. This supports future virtual files (self, mounts, etc.).
The fd uses a discriminated union: LogFd ({ kind: 'log', snapshot }) alongside the existing ProcFd ({ kind: 'proc', pid, file, content }).
Future: Cursor-Based Streaming
For dmesg -w follow mode, the fd would need to track an internal sequence cursor instead of a text snapshot. Each read() would return new entries since the last read, blocking when caught up. This matches the createPipe() async blocking pattern but is not yet implemented.
Component 3: Shell Tracing (set -x)
A shell interpreter feature. Independent of kernel logging.
Behavior (matches bash)
$ set -x$ echo $HOME+ echo /home/user/home/user$ ls | grep foo+ ls+ grep foo$ set +xset -xenables,set +xdisables- Output written to stderr with
+prefix - Shows expanded command (after variable/glob expansion)
- Subshell depth indicated by prefix depth (
+,++,+++)
Implementation
The interpreter maintains a shellOpts: ShellOpts object ({ xtrace: boolean }) in its closure state. The set builtin accesses this via (proc as { shellOpts?: ShellOpts }).shellOpts — a type assertion since shellOpts is not part of the public ProcContext interface (it’s shell-internal state).
Key details:
- Child interpreters (redirects, blocks, functions, pipelines) receive the same
shellOptsreference — changes propagate - Subshells and command substitutions receive a shallow copy (
{ ...shellOpts }) — changes don’t propagate back - The xtrace check runs in
executeSimple()after alias expansion and before command dispatch createInterpreter()accepts an optionalparentShellOptsparameter for inheritance
Component 4: Opt-in Kernel Tracing (TRACE env var)
Fine-grained debug tracing. The strace equivalent.
Behavior
TRACE=fs,proc command— traces specified subsystems for that process tree- Trace entries go to the ring buffer at severity
Debug(7) - When tracing is off (default), no debug entries written — zero overhead
- Per-process via env var inheritance.
TRACE=fs shtraces the shell and its children
Traceable Subsystems
Implemented:
| Subsystem | Operations | Data |
|---|---|---|
fs | open, read, write, close, stat | paths, fd numbers, byte counts, flags |
Planned:
| Subsystem | Operations | Data |
|---|---|---|
proc | spawn, exec, exit, signal | pids, argv |
mount | mount, unmount | paths, fileserver types |
ns | namespace resolution | path → mount → fileserver |
Guard
Inside createKernel(), a shouldTrace(pid, subsystem) helper reads the process’s TRACE env var:
function shouldTrace(pid: Pid, subsystem: string): boolean { const proc = processes.get(pid) if (proc === undefined) return false const tracing = proc.env['TRACE'] if (tracing === undefined) return false return tracing.split(',').includes(subsystem)}Each traceable operation is guarded with if (klog !== undefined && shouldTrace(pid, 'fs')). The klog !== undefined check short-circuits when no log is configured (e.g., in tests without logging).
Component 5: Test Utilities
Not a kernel feature. Test helpers for diagnostics.
Silent by Default
The ring buffer accumulates entries during tests. Nothing reads them unless the test explicitly opts in.
Assertion Helper
const entries = kernelLog.entries()expect(entries).toContainEqual( expect.objectContaining({ msg: expect.stringContaining('started /bin/sh') }))Dump on Failure (opt-in)
useLogDumpOnFailure(kernelLog)Registers a vitest afterEach hook. On test failure (ctx.task.result?.state === 'fail'), formats and prints all ring buffer entries. Silent on success.
Component 6: dmesg Bin
A system bin (src/bins/system/) for human-readable log output. Reads from /proc/log, decodes <pri> into labels, formats timestamps.
$ dmesg[0.600] kern.info pid:1 started echo hello[1.600] kern.info pid:1 exited with code 0[1.900] kern.info pid:2 started echo world
$ dmesg -l err,warn # filter by severity (accepts: err, error, warn, warning, etc.)$ dmesg -f kern,daemon # filter by facility$ dmesg -r # raw output (pass through <pri> prefix)Not yet implemented: dmesg -w (follow mode) — requires cursor-based streaming on /proc/log.
Process Logging (Bins and WASM)
Bins and WASM programs do not use the kernel logging system. They use the existing pattern:
// bins write to stderr — unchangedawait proc.stderr.write(`cat: ${path}: No such file or directory\n`)// WASM programs use Emscripten's print/printErr — unchanged// print() → proc.stdout, printErr() → proc.stderrThe ring buffer is for system internals. Process output is for processes. These are separate concerns with separate channels.
Multi-Instance Behavior
Each UnixInstance boots its own kernel with its own KernelLog. Ring buffers are per-instance, fully isolated. No shared state between instances.
Outstanding Work
Near-term items that build on the implemented foundation:
- Init/supervisor logging: add daemon-facility events (service started, exited, restarting, gave up) to
src/runtime/init/init.ts. Requires threadingKernelLogthrough the init boot path. - Package system logging: add local0-facility events (installed, failed) to
src/runtime/pkg/evaluator.ts. dmesg -wfollow mode: requires cursor-based streaming on/proc/log(replace snapshot-at-open with seq-tracking fd).- Additional TRACE subsystems:
proc,mount,nssubsystems forshouldTrace().
Future Considerations (Not In Scope)
- Syslog service: userspace daemon for multi-destination log routing. Deferred until enough services exist to justify configurable routing. The
priencoding ensures forward compatibility. - Log persistence: a service that reads
/proc/logand writes to a file. The ring buffer is in-memory and lost on shutdown. - Structured log access: the text format requires parsing. A future JSON or binary variant of
/proc/logcould serve structured consumers directly. The ring buffer already stores structuredKernelLogEntryobjects — this is purely a presentation concern. set -e/trap: deferred per existing architecture decisions inerrors/README.md.
Error Flow Integration
Logging complements, does not replace, the existing error propagation model. Errors still flow through the system as described in the errors architecture: fileserver errors propagate transparently, uncaught bin exceptions become stderr + exit code 1, pipeline failures cascade via EPIPE/EOF.
The ring buffer captures the system’s perspective on these events (process exited, fileserver error occurred). The process stderr captures the bin’s perspective (what went wrong from its point of view). Both exist simultaneously, serving different consumers.