Runtime Architecture
Overview
The Runtime is the layer between Image and Instance. It owns the boot procedure — assembling the namespace, creating the kernel, and constructing the running instance. The Runtime knows what platform it’s on and provides platform-appropriate capabilities.
UnixBuilder --> UnixImage --> Runtime --> UnixInstance (build) (data) (boot) (running)Design Principles
-
The Runtime is a namespace assembler (Plan 9). Its job is to construct the right mount table for each instance — the right fileservers at the right paths. Everything else (process lifecycle, I/O routing, file access) is handled by the kernel and existing fileservers.
-
PlatformCapabilities are internal. Processes never see platform capabilities. They use the namespace. If a process needs to load bytes, it reads a file. If it needs to fetch a URL, it uses httpFS. The Runtime wires platform-appropriate implementations behind the fileservers.
-
Factory injection. The Runtime constructs bin factories (createWasiBin, createWasmBin) with platform capabilities closed over. The resulting BinFunction is opaque — the process calls it without knowing what platform it’s on.
-
RuntimeConfig vs BootOpts. Platform-level config (capabilities, adapter options) is set once at Runtime construction. Per-instance config (tty, cwd, volumes) is provided at boot time.
-
The kernel routes bytes. Nothing more. The Runtime is a userspace component — it does not extend the Kernel interface or ProcContext. It consumes the kernel, not modifies it.
Architecture
Type Hierarchy
PlatformCapabilities — what the host provides (loadAsset, importEsm, fetch, compileWasm) |RuntimeConfig — platform-level config (capability overrides) |Runtime — boot(image, opts) -> UnixInstance |BootContext — data contract: Image exposes frozen config to RuntimeBoot Sequence
The Runtime’s boot(image, opts) method executes these steps (extracted from the former UnixImageImpl.boot()):
1. Read BootContext from image (rootFs, mounts, bins, env, services, catalogEntries)2. Create writable upper layer (memoryFS)3. Create overlay root: overlayFS(upper, rootFs)4. Mount devFS at /dev5. Create processTable + KernelLog6. Mount procFS at /proc (with signal + getNs callbacks — signal callback captures `kernel` via late-binding closure; kernel is created in step 14)7. Mount consFS at /dev/cons (if tty provided) via mountConsTerminal helper8. Mount userFS at /dev/user9. Mount volumes from BootOpts10. Mount srvFS at /srv11. Write catalog metadata to /pkg/.catalog/12. Build environment (DEFAULT_ENV + image env + runtime env)13. Build catalogInstall callback (closes over upper layer + capabilities)14. Create kernel with mount table + callbacks15. Determine PID 1 (init with services, or shell)16. Construct UnixInstance (boot, spawn, wait, shutdown)Steps 1-16 are in bootFromImage() in src/runtime/platform/boot.ts.
Platform Capabilities
Each platform provides four capabilities:
| Capability | Node.js | Browser | Test |
|---|---|---|---|
loadAsset(url, relativeTo?) | node:fs/promises readFile | fetch() -> arrayBuffer | throws |
importEsm(source) | Buffer -> base64 data URI -> import() | Blob -> createObjectURL -> import() | throws |
fetch(url, init?) | globalThis.fetch | globalThis.fetch | throws |
compileWasm(binary) | WebAssembly.compile | WebAssembly.compile | WebAssembly.compile |
Capabilities flow into the boot sequence via closure injection:
- WASM bin factories receive
caps.loadAssetandcaps.compileWasmat construction time - The package evaluator receives
caps.importEsmvia thefsResolverparameter - The package registry fetcher uses
caps.fetch
Module Structure
src/runtime/platform/ types.ts — Runtime, PlatformCapabilities, RuntimeConfig, BootContext interfaces boot.ts — bootFromImage(), createRuntime() — the core boot procedure node.ts — nodeRuntime() factory + Node capabilities + nodeStdio adapter browser.ts — browserRuntime() factory + Browser capabilities + xtermStdio adapter test.ts — testRuntime() factory + Test capabilities (throws for most)Entry Points (package.json exports)
@fishnet/core — core: Unix(), stdSystem(), types@fishnet/core/node — nodeRuntime(), nodeStdio@fishnet/core/browser — browserRuntime(), xtermStdio@fishnet/core/test — testRuntime()The wildcard subpath export "./*" also supports deep imports like @fishnet/core/kernel/types. All imports use the @fishnet/core package specifier with no .js extensions (moduleResolution: “bundler”).
Node.js code (node:fs, node:path, node:url) is isolated to @fishnet/core/node. Browser bundles never see it.
Image / Runtime Boundary
Image (pure data)
interface UnixImage { createBootContext(): BootContext // read-only view of image contents extend(): UnixBuilder // create a new builder with this image as base}The Image holds: frozen rootFS (stacked overlay layers from .build() and .run() steps — each .run() step produces a frozen upper layer overlaid on the previous), merged config (bins, env, mounts, services, catalogs). The rootFs in BootContext is this frozen stack. bootFromImage creates another overlay on top — the writable upper layer for runtime mutations.
Runtime (boot procedure)
interface Runtime { boot(image: UnixImage, opts?: BootOpts): Promise<UnixInstance> readonly capabilities: Readonly<PlatformCapabilities>}The Runtime holds: platform capabilities (fixed at construction). Each boot() call creates a fresh instance with its own overlay, kernel, process table, and namespace.
BootContext (the handoff)
interface BootContext { readonly rootFs: Fileserver & ExecCapable readonly mounts: Readonly<Record<string, Fileserver>> readonly bins: Readonly<Record<string, BinFunction>> readonly env: Readonly<Record<string, string>> readonly services: readonly ServiceDef[] readonly catalogEntries: Readonly<Record<string, () => Extension>>}All fields are Readonly — the Image is frozen, the Runtime must not mutate it. Multiple boots from the same image create independent instances.
Containerd Mapping
For readers familiar with container runtimes:
| containerd/runc | fishbowl |
|---|---|
| OCI Image | UnixImage (frozen rootFS + config) |
| OCI Runtime Spec | BootOpts (mounts, env, cwd) |
| containerd runtime shim | Runtime (platform capabilities) |
runc create + runc start | runtime.boot(image, opts) |
| rootfs overlay | overlayFS(upperFs, image.rootFs) |
/proc mount | procFS(processTable, log, signal, getNs) |
/dev mount | devFS() |
| PID 1 / init | makeInit(services) or shell |
| SIGTERM -> grace -> SIGKILL | instance.shutdown() |
Lifecycle
Construction: nodeRuntime({ prompt: '$ ' }) | vBoot: runtime.boot(image, { tty, cwd }) | vRunning: instance.boot() → shell/init as PID 1 instance.spawn() → additional processes instance.wait() → wait for PID 1 exit | vShutdown: instance.shutdown() SIGTERM all → 5s grace → SIGKILL survivors → allSettled → kernelLog.dispose()What the Runtime Does NOT Do
- Extend the kernel. The kernel interface is unchanged. The Runtime consumes
createKernel(). - Modify ProcContext. No new fields on ProcContext. Capabilities flow through closures, not process context.
- Own the build path.
UnixBuilderImpl.build()and.run()stay in builder.ts. Build-time kernels are separate from boot-time. - Provide security boundaries. Namespace isolation is a convention. Real sandboxing is a host-level concern (Phase C).
- Auto-detect platform. Consumers explicitly choose
nodeRuntime(),browserRuntime(), ortestRuntime().