# @nibling/kernel — synthesized architecture Synthesis of arena 1 (candidates: claude-sonnet-5.5 [base], gpt-6.1-sol-pro, grok-4.7). Design record: `sketches/arena-1/`. This file is the implementation contract. ## Problem A browser-run Node-compatible runtime, built from scratch. JavaScript runs in the browser's own engine; the native parts of Node reach browser primitives first, falling through to WASM only where the browser genuinely lacks a Node contract (synchronous incremental hashing, sync zlib, POSIX metadata that OPFS cannot express). We may not inspect WebContainers source; everything here derives from public specs (MDN, Node docs, WASI, OPFS/IDB/Workers). The non-obvious shape: Node semantics require *one shared filesystem across processes*, but OPFS sync access handles are exclusive locks and the browser gives no shared memory between the page and workers without cross-origin isolation. The design therefore makes one worker the sole filesystem owner and gives every other worker a typed RPC client with a sync mailbox. Persistence is layered so the first milestone needs none of it. ## Usage (caller's view) ### Call site 1: embedder boots a runtime and runs a script ```ts import { boot } from "@nibling/kernel"; const runtime = await boot(); // options: { base, serviceWorker } runtime.writeTree({ "/app/main.js": "…", "/app/lib/util.js": "…" }); const proc = runtime.exec("/app/main.js"); proc.stdout.pipeTo(consoleSink); const code = await proc.exited; // exit code ``` ### Call site 2: user JS inside the runtime (unchanged Node code) ```js const fs = require("fs"); const { createHash } = require("crypto"); const { spawn, fork } = require("child_process"); const zlib = require("zlib"); const data = fs.readFileSync("in.dat"); // blocks the worker on the kernel mailbox const h = createHash("sha256").update(data).digest("hex"); // in-thread WASM, no RPC const gz = zlib.gzipSync(data); // in-thread WASM fs.writeFileSync("out.txt", gz); const child = fork("./helper.js"); child.on("message", (m) => console.log("child said", m)); child.send({ hi: true }); process.on("SIGINT", () => child.kill()); ``` `readFileSync` blocks via `Atomics.wait` on a mailbox; `createHash`/`gzipSync` stay in-thread through the WASM residue; `fork` is a Web Worker plus a transferred `MessagePort`. ### Call site 3: contributor adds a builtin or a native algorithm ```ts // compat/os/index.ts — one file plus one manifest line export default defineBuiltin({ id: "os", exports: ({ process }) => ({ EOL: "\n", platform: () => "linux", availableParallelism: () => navigator.hardwareConcurrency, }), }); // compat/manifest.ts: os: () => import("./os/index.ts"), // Adding a hash algorithm: add it to native/abi.json, implement in native/src/hash.c, // add a row to compat/crypto/algorithms.ts. `gen-abi` regenerates Rust + TS bindings. // CI fails if the built .wasm exports differ from abi.json, or if any node: builtin id // is neither in the manifest nor in OUT_OF_SCOPE. ``` ## Shape ### Actors and ownership ``` main thread (host/) kernel worker (kernel/) runtime workers (runtime/), 1 per thread ─────────────────── ────────────────────── ──────────────────────────────────────── boot(), Kernel, Process ◄──► inode table, fd tables, pipes, ◄──► user JS + compat/ + loop + module loader tty sinks, SW registrar process table, resolver, + residue.wasm instance (sync hash/zlib) store (memory | OPFS), journal, Web Lock ▲ Service Worker (sw/) ── fetches ESM source for the virtual origin ``` - **The kernel worker is the only writer of the filesystem and the process table.** A filesystem is genuinely shared state, so the sharing is structural: one owner, everyone else RPC clients (separate-before-serializing-shared-state). All inode-table mutation is synchronous code with no `await` between check and commit. Per-inode async queues serialize only the byte I/O that awaits a handle. OPFS sync handles are exclusive locks, which would force single-writer anyway. - **User JS never runs on the main thread**, so every sync API can block with `Atomics.wait`. - **A runtime worker is stateless about the filesystem**: module cache, loop, WASM instance, everything else an RPC to the kernel. - **The kernel never synchronously calls into a runtime worker** (one-way dependency, from C2). Kernel→runtime messages are only completions/signals, delivered as poll-phase events. - The Service Worker arrived with the ESM milestone and is now required: guest ESM and guest HTTP depend on it, so `boot()` refuses without one rather than run CJS alone. ### Data structures All branded at the boundary, trusted inside (`kernel/vfs/types.ts`): ```ts type Brand = T & { readonly [brand]: B }; export type Ino = Brand; // monotonic, never reused → blob name is stable export type Fd = Brand; // per-process, lowest-free export type Pid = Brand; export type Tid = Brand; export type Mode = Brand; // 0..0o7777 export type Name = Brand; // one dentry: no '/', no NUL, not "."/"..", ≤255 bytes export type FsPath = Brand; // ≤4096 bytes, may be relative, may contain ".." // ".." is never collapsed lexically; it is walked physically so symlinks resolve like POSIX. export function parseFsPath(raw: string): Result; // only constructor export function parseName(raw: string): Result; export const Errno = { ENOENT: 2, EEXIST: 17, ENOTDIR: 20, EISDIR: 21, EBADF: 9, ENOTEMPTY: 39, ELOOP: 40, ENOSPC: 28, EIO: 5, EINVAL: 22, ENAMETOOLONG: 36, EXDEV: 18, EMFILE: 24, ENOSYS: 38, EAGAIN: 11, EPERM: 1, EACCES: 13 } as const; export type ErrnoCode = keyof typeof Errno; export type Result = | { ok: true; val: T } | { ok: false; err: { code: C; syscall: SyscallName; path?: FsPath; dest?: FsPath } }; interface InodeBase { ino: Ino; mode: Mode; nlink: number; atimeMs: number; mtimeMs: number; ctimeMs: number } export interface FileInode extends InodeBase { kind: "file"; size: number } export interface DirInode extends InodeBase { kind: "dir"; entries: Map; parent: Ino } export interface SymlinkInode extends InodeBase { kind: "symlink"; target: FsPath } export type Inode = FileInode | DirInode | SymlinkInode; // kind-union: a dir cannot have a size export interface InodeTable { root: Ino; get(ino: Ino): Inode | undefined; nextIno: number; seq: number } ``` Hard links exist (`nlink`), unlike C3's `names: 0|1`; `link()` is a mutation, `unlink` of the last name plus zero open fds retires the inode and GCs its blob. **Persistence is event-sourced** (C1), with C2's receipt rule for ambiguous commits: ```ts export type Mutation = { seq: number } & ( | { t: "create"; ino: Ino; kind: "file" | "dir" | "symlink"; parent: Ino; name: Name; mode: Mode; target?: FsPath; at: number } | { t: "link"; ino: Ino; parent: Ino; name: Name; at: number } | { t: "unlink"; parent: Ino; name: Name; at: number } | { t: "rename"; from: [Ino, Name]; to: [Ino, Name]; at: number } | { t: "setattr"; ino: Ino; mode?: Mode; atimeMs?: number; mtimeMs?: number } | { t: "resize"; ino: Ino; size: number; at: number } // journaled AFTER the blob write ); export function applyMutation(t: InodeTable, m: Mutation): void; // pure; skips m.seq <= t.seq export function encodeFrame(m: Mutation): Uint8Array; // [len u32][crc32 u32][payload] export function decodeFrames(bytes: Uint8Array): { muts: Mutation[]; validBytes: number }; export interface Store { readonly meta: MetaLog; readonly data: BlobStore; close(): Promise; } export interface MetaLog { load(): Promise<{ snapshot: Uint8Array | null; journals: Uint8Array[] }>; append(frame: Uint8Array): void; // sync-handle write, buffered; never awaits flush(): Promise; // 25 ms group-commit; also on fsync/pagehide checkpoint(snapshot: Uint8Array, lastSeq: number): Promise; } export interface BlobStore { read(ino: Ino, at: number, len: number): Promise; write(ino: Ino, at: number, data: Uint8Array): Promise; truncate(ino: Ino, size: number): Promise; remove(ino: Ino): Promise; flush(ino: Ino): Promise; list(): AsyncIterable; } export function openStore(cfg: StoreConfig): Promise; // Web Lock `nib:`; StoreBusyError if held ``` Two store implementations (`store-memory.ts`, `store-opfs.ts`) keep the pure VFS core testable without a browser. OPFS layout: `/nib//{meta/snap., meta/journal., data/}`, LRU-capped at 64 sync access handles. Recovery: load snapshot, replay journals in gen order skipping `seq <= t.seq`, GC orphan blobs. **Commit receipts** (C2): an ambiguous commit outcome is resolved by reading its receipt, never by assuming; a mutation whose frame reached the journal before the ack is idempotent on replay by seq. **Syscall spec** — one typed table both sides share; the wire is type-checked end to end. This is the single API boundary between compat and kernel, not a pass-through chain (judge-flagged C1 defect fixed: mailbox spill policy moved out of the spec into rpc.ts). ```ts export interface SyscallSpec { open: { args: { path: FsPath; flags: OpenFlags; mode: Mode }; ret: Fd }; close: { args: { fd: Fd }; ret: void }; read: { args: { fd: Fd; len: number; pos: number | null }; ret: Bytes }; write: { args: { fd: Fd; data: Bytes; pos: number | null }; ret: number }; stat: { args: { path: FsPath; follow: boolean }; ret: StatRecord }; fstat: { args: { fd: Fd }; ret: StatRecord }; readdir: { args: { path: FsPath }; ret: DirentRecord[] }; mkdir: { args: { path: FsPath; mode: Mode; recursive: boolean }; ret: FsPath | null }; rename: { args: { from: FsPath; to: FsPath }; ret: void }; unlink: { args: { path: FsPath }; ret: void }; rmdir: { args: { path: FsPath }; ret: void }; symlink: { args: { target: FsPath; path: FsPath }; ret: void }; readlink: { args: { path: FsPath }; ret: FsPath }; realpath: { args: { path: FsPath }; ret: FsPath }; utimes: { args: { path: FsPath; atimeMs: number; mtimeMs: number }; ret: void }; truncate: { args: { fd: Fd; size: number }; ret: void }; fsync: { args: { fd: Fd }; ret: void }; watch: { args: { path: FsPath; recursive: boolean }; ret: WatchId }; // events as completions resolve: { args: { request: string; parent: FsPath; kind: "require" | "import" }; ret: ResolvedModule }; loadModule: { args: { path: FsPath }; ret: { source: Bytes; format: ModuleFormat; pkg: PkgScope | null } }; spawn: { args: SpawnSpec; ret: { pid: Pid } }; spawnSync:{ args: SpawnSpec; ret: SpawnSyncResult }; // reply withheld until child exit kill: { args: { pid: Pid; sig: Signal }; ret: void }; sigaction:{ args: { sig: Signal; handled: boolean }; ret: void }; exit: { args: { code: number }; ret: never }; // never replies; kernel terminates worker npm: { args: InstallRequest; ret: InstallOutcome }; // async only; npm's error is the outcome's npmLookup:{ args: ExecLookupRequest; ret: ExecLookup }; // async only; libnpmexec's missingFromTree } type Req = { t: "req"; id: number; op: SyscallName; args: unknown; sync: boolean }; type Res = { t: "res"; id: number } & ({ ok: true; val: unknown } | { ok: false; err: WireErr }); export function decodeRequest(raw: unknown, caller: PortOwner): Result; // caller identity comes from the PORT, never the payload. FsPath/Name minted only at edges: // compat/fs (user input), host (host input), decodeRequest (wire). ``` Invariants: every `Req` gets exactly one `Res` (one `try/finally` in `serve()`; unexpected throw becomes `EIO`). `exit` never replies. Requests carry a **generation + receipt** (C2) so a retried append/rename never duplicates its effect. **Sync mailbox** (one SAB per runtime thread, handed to the kernel at attach): ``` Int32 header: [0] state (0 IDLE, 1 PENDING, 2 DONE) [1] errno [2] jsonLen [3] bytesLen [4] pendingSignals bitmask [5] kernel heartbeat (bumped every 500 ms) arena (1 MiB): [json result][raw bytes] ``` Protocol (C1 layout + C2 lost-wakeup fix): requester checks `state == DONE` **before** setting `state = PENDING` and waiting (sequence-check-before-wait prevents a lost wakeup when the reply lands between postMessage and the wait). `Atomics.wait` in a 2 s loop; heartbeat staleness > 10 s throws `ERR_NIB_KERNEL_LOST`. A reply larger than the arena returns the head plus an open `rest: Fd` (spill policy lives in rpc.ts, not in the syscall spec). ### Module map ``` @nibling/kernel/ ├─ src/ │ ├─ host/ PUBLIC. main thread. boot()/Kernel/Process hide everything below │ │ ├─ index.ts boot, Kernel, Process, ExecSpec, HostFs, errors │ │ ├─ supervisor.ts spawns kernel worker, tty sinks, pagehide→flush, SW registration │ │ └─ host-fs.ts string-path facade → syscall on Pid 0 (parses to FsPath at the edge) │ ├─ kernel/ kernel worker. sole owner of fs + process table │ │ ├─ main.ts openStore → recover → serve ports │ │ ├─ rpc.ts SyscallSpec, decodeRequest, serve(), mailbox writer, per-inode queues │ │ ├─ vfs/ types|path|mutation|ops(PURE)|fdtable|recover|store|store-memory|store-opfs │ │ ├─ modules/ resolve.ts (ONE resolver, CJS+ESM, pure over FsView) | transform.ts │ │ └─ proc/ table.ts | pipe.ts (bounded rings, backpressure) | worker-factory.ts │ ├─ runtime/ per-thread worker. user JS lives here │ │ ├─ main.ts, bootstrap.ts (globals, primordials, entry), syscall.ts (call/callSync) │ │ ├─ loop/ scheduler.ts | timers.ts | ticks.ts | handles.ts | clock.ts (Real + Virtual) │ │ ├─ modules/ cjs.ts | esm-bridge.ts | builtins.ts │ │ └─ native/ abi.gen.ts (GENERATED) | heap.ts | index.ts │ ├─ compat/ Node API layer. one dir per builtin │ │ ├─ manifest.ts, define.ts, builtin-names.gen.json │ │ └─ fs/ path/ process/ console/ util/ events/ buffer/ string_decoder/ url/ os/ │ │ timers/ assert/ crypto/ zlib/ stream/ child_process/ worker_threads/ │ ├─ sw/sw.ts virtual origin: /~/vfs/?v=., /~/builtin/ ├─ native/abi.json SINGLE SOURCE OF TRUTH: functions, enums, alg ids ├─ scripts/ gen-abi.ts | compat-report.ts | serve.ts (sets COOP/COEP) └─ tests/ unit (vitest) | browser (Playwright) | fixtures/golden-loop-traces ``` Builtins stay cheap to add: `defineBuiltin({ id, exports(ctx) })` with `BuiltinContext { sys, native, loop, process, require }`. Lazy manifest imports; unused builtins never load. `OUT_OF_SCOPE` ids throw `ERR_NIB_UNSUPPORTED` at require. `compat-report.ts` fails CI on any `node:` builtin id that is neither supported nor explicitly out of scope. ### Browser-first precedence table The WASM ABI is derived from the last column only. Native primitive as substrate, Node API shape on top. (Rows merged from C1 + C3; C3's finer crypto/zlib splits win.) | Node API family | Browser primitive (substrate) | Node-semantics layer | WASM fallback | |---|---|---|---| | `fs` bytes | OPFS sync access handles (read/write/truncate/flush/getSize) in kernel | blob-per-inode | none | | `fs` metadata (mode, symlink, hardlink, mtime, ino, readdir order) | none — OPFS expresses none of it | JS inode table + journal | none. Plain JS beats an ABI crossing | | `fs` exclusivity, fsync, quota | Web Locks, `handle.flush()`, `storage.estimate/persist`, QuotaExceededError→ENOSPC | — | none | | `fs.watch` | none (no OPFS observer) | kernel emits from its own mutation stream | none | | `path`, `querystring` | none | pure JS posix | none | | `url` | native `URL`/`URLSearchParams` | `fileURLToPath`, `pathToFileURL`, legacy `url.parse` | none | | `Buffer`, `string_decoder` | `Uint8Array`, TextEncoder/TextDecoder, atob/btoa, toBase64/toHex when present | Buffer extends Uint8Array; latin1 is a JS byte loop (TextDecoder latin1 ≠ Node latin1) | none | | `crypto.webcrypto`, async digest | `crypto.subtle` returned as the web API | — | none | | `randomBytes/randomFill` | `crypto.getRandomValues` (chunked at its size limit), `crypto.randomUUID` | — | none | | pbkdf2 SHA-256/512 | `crypto.subtle.deriveBits` | Node option mapping | none | | other pbkdf2/scrypt, createHmac, createHash, sync `hash()` | none — browser has no sync incremental digest | Node API shape; until step 5, md5/sha1/sha256/sha512 in TypeScript (`src/runtime/digest.ts`), byte-identical | incremental SHA-256/SHA-512 (+ future algs via abi.json) | | `events` | EventTarget | one internal listener per event name walks Node listeners; throw inside a listener stops the walk, rethrown after dispatch (DOM exception reporting must not swallow it) | none | | `stream` | Readable/Writable/TransformStream queues | Node stream state machine + backpressure; toWeb/fromWeb adapters | none | | `process`, timers, nextTick, setImmediate | Worker global, performance.now, native timers, MessageChannel, native microtasks | owned queues | none | | `child_process`, `worker_threads` | Dedicated Workers, MessagePorts, structured clone/transfer, SAB | PCB, pipes, signals | none | | zlib async (gzip/deflate/inflate) | CompressionStream/DecompressionStream | Node option mapping | none | | zlib sync (gzipSync etc.) | none — streams are async | Node API shape | deflate/inflate (deflate64, brotli: out of scope v1) | | `console`, `util` | browser console + captured stdio | inspect/format/promisify | none | | `fetch`, web streams globals | browser implementations, CORS preserved | undici-shape facade | none | | native addons | no browser equivalent | `.node` rejected with `ERR_DLOPEN_FAILED`; portable WASM modules are a future extension | — | | TypeScript sources | none — the browser has no TS eraser, and CJS `require` needs it *synchronously* | Node's strip-only contract: `.ts` follows nearest `type` (syntax-detected on the stripped source when there is none, as `.js` is), `.mts`→ESM, `.cts`→CJS; `import type` erased before resolution; no extension rewriting; `.tsx`/enums/node_modules-`.ts` refused with Node's own codes | `@swc/wasm-typescript` `transformSync` (the engine behind Node's amaro), wasm streamed from `dist/runtime/swc.wasm` at the first `.ts` load (a sync `require` waits on it), failure contained as `ERR_NIB_TS_ENGINE` | ### Browser-native audit The precedence table is the plan; this is what the code does today. Each row names the browser primitive a Node concern rides on, or why it is custom. | Node concern | Primitive used | Custom, and why | |---|---|---| | processes | Dedicated Workers (kernel worker, one runtime worker per process), MessagePorts | the process table and its lifecycle (exit codes, `kill()`, port freeing): the browser has no process model | | sync syscalls | `SharedArrayBuffer` mailbox + `Atomics.wait` | the mailbox protocol and heartbeat; the browser has no sync worker RPC | | ESM | the browser's module linker over a Service Worker virtual origin | specifier rewriting (es-module-lexer) and CJS facades: the linker cannot resolve `node_modules` or bare builtins | | event loop | MessageChannel tasks, native microtasks, one armed `setTimeout` | Node's phases, timer heap and nextTick queue: the browser's task order is not libuv's | | http transport | Service Worker `fetch` interception, MessagePort bridge | `IncomingMessage`/`ServerResponse` and a per-request virtual socket; there is no TCP | | WebSocket upgrades | a MessagePort per connection, transferred page → SW → host → kernel → runtime; `crypto.subtle` SHA-1 for the handshake check | the RFC 6455 client in the shim (`src/shim/`): a Service Worker cannot intercept WebSockets, so the page speaks the protocol over the port | | web globals | the worker's own `fetch`, `URL`, streams, `TextEncoder`, `crypto`, `queueMicrotask` | only what workers lack: `process`, `Buffer`, `global`, loop-backed timers | | Buffer | `Uint8Array`, TextEncoder/Decoder, `fromBase64`/`toBase64`/`fromHex`/`toHex` when present | lenient base64/hex decoding (the native decoders are strict), latin1/ascii/utf16le loops (TextDecoder's latin1 is windows-1252) | | url | `URL`, `URLSearchParams` | legacy `url.parse`/`format`, `fileURLToPath`/`pathToFileURL`: no browser API has Node's legacy shapes | | crypto random | `crypto.getRandomValues`, `crypto.randomUUID`, `crypto.subtle` as `webcrypto` | none | | crypto hashing | none: `crypto.subtle.digest` is async and one-shot | md5/sha1/sha256/sha512 in TypeScript (`digest.ts`) until the residue: `createHash` is sync and incremental | | zlib | CompressionStream/DecompressionStream (gzip, deflate, deflate-raw) | error mapping, data-after-end recovery, unzip detection, Node's stream event order; `crc32` in JS; sync forms wait for the residue; brotli and zstd refuse | | fs persistence | none yet: in-memory store | OPFS sync access handles are pending (step 4) | | fs.watch | none: the kernel is the only writer, so its own mutations are the event source | the inotify event model and per-thread delivery; the browser has no file notifications for an in-memory store | | dns | none: a page cannot run getaddrinfo | a hosts table (localhost, IP literals); queries refuse | | http client | none yet | `http.request`/`https`, over `fetch`, are pending | ### WASM ABI (residue module) `native/abi.json` is the single source of truth; `gen-abi.ts` generates `runtime/native/abi.gen.ts` and the C/Rust declarations. Freestanding C (no Emscripten FS, no WASI, no host imports — the v1 import table is empty), instantiated at process boot so `createHash` never has an async first-call path. Private growable memory; views rebuilt after any growing export. | Export | C-style signature | Contract | |---|---|---| | `memory` | linear memory | private to one process; never exposed to user Buffers | | `nib_abi_version` | `uint32_t(void)` | reject other major versions | | `nib_alloc` / `nib_free` | `uint32_t(uint32_t len)` / `void(uint32_t,uint32_t)` | exact allocation pair, exactly once | | `nib_hash_new` | `int32_t(uint32_t alg, uint32_t out_handle_ptr)` | 1=SHA-256, 2=SHA-512; streaming handle | | `nib_hash_update` | `int32_t(uint32_t h, uint32_t ptr, uint32_t len)` | borrows input during call only | | `nib_hash_final` | `int32_t(uint32_t h, uint32_t out_ptr, uint32_t out_cap)` | consumes handle only on success | | `nib_hash_drop` | `int32_t(uint32_t h)` | releases abandoned hash; stale handles fail | | `nib_z_new` / `nib_z_run` / `nib_z_drop` | streaming codec handles | same handle discipline as hash | Status 0 = success; negative enums for invalid handle/argument/state/allocation — not POSIX errno; `native/index.ts` maps them to Node crypto/zlib errors. Handles are index + generation counter. Traps poison the instance (re-instantiate on next use), never resurface as `EINVAL`. One copy in, one small copy out; no borrowed pointers across calls, no shared linear memory, no JS callbacks into the module. **`fs.readFile` trace (no WASM hop — OPFS already does the native read):** `require('fs').readFile('/app/a.txt', cb)` → `compat/fs` parses path/encoding (sync throw on bad args), retains a loop resource → async `readFile` syscall: kernel walks the path in the inode table, opens/reads the OPFS blob via sync access handle, transfers bytes back → reply lands as a poll-phase completion → compat constructs the Buffer or decodes UTF-8 → `cb(null, data)` once. Errors: kernel emits `{ code: 'ENOENT', syscall: 'open', path }`; compat formats the Node error with code/errno/syscall/path. No browser exception escapes. **`createHash` trace (the WASM hop):** `createHash('sha256')` → `compat/crypto/hash.ts` → `native/index.ts`: `nib_alloc(64)` scratch → `nib_hash_new(1, ptr)` handle → each `update(data)` copies into scratch, calls `nib_hash_update`, frees scratch → `digest()`: `nib_hash_final` writes 32 bytes, adapter copies into a fresh Buffer, hex-encodes in JS, frees → status≠0 or trap throws NodeError after `try/finally` cleanup. ### Event loop mapping `runtime/loop/scheduler.ts` is a virtual libuv loop running **one user callback per browser task**; microtasks drain natively between callbacks (Node ≥ 11 behavior). Wake via `MessagePort.postMessage` (no 4 ms clamp); a single armed `setTimeout` when only timers are pending; idle otherwise. | Node phase | Our structure | Browser primitive | Notes | |---|---|---|---| | timers | min-heap `(expiryMs, seq)`; clamp ≥1 ms; unref | armed setTimeout / phase-entry check vs cached `loopNow` | our heap decides order; 4 ms clamp and throttling change *when*, never *order* | | pending | FIFO of deferred callbacks (fs arg errors reported async, deferred close errors) | MessageChannel step | | | idle/prepare | internal hooks only (flush batched stdout) | same step | | | poll | FIFO of completions: syscall replies, stream data, watch events, child exit/IPC, fetch/WS settle | onmessage/promise-settle handlers only enqueue + wake | | | check | setImmediate queue, snapshotted at entry | MessageChannel step | immediates scheduled inside run next iteration | | close | FIFO of 'close' emissions | MessageChannel step | | | nextTick | drained synchronously after every callback | same stack | called from a microtask (ESM continuation) → schedules a step that starts by draining ticks | | microtasks | native | browser checkpoint | CJS main: `then(a); nextTick(b)` → `b, a`. ESM main: `a, b` (matches Node) | | unhandledRejection | worker event → completion | | no listener: print + exit 1 | | uncaughtException | try/catch around each callback | | no listener: print, exit 1 | Determinism: the loop takes a `Clock`; tests use a `VirtualClock` advancing only when idle, so `setTimeout(0)` vs `setImmediate` is deterministic; golden traces recorded from Node docs stored as fixtures. The one real nondeterminism (1 ms timer vs immediate from main) matches Node. Sync-op policy: `fs.*Sync`/`require`/`readSync` block on the mailbox (legal because user code only runs in workers; heartbeat watchdog). `crypto.*Sync`/`zlib.*Sync` run in-thread WASM, no RPC. `spawnSync/execSync` of `node` blocks with the kernel withholding the reply until child exit (child needing its blocked parent deadlocks, as in Node). Sync network refused with `ERR_NIB_UNSUPPORTED_SYNC`. Without `crossOriginIsolated`, `boot()` refuses (`IsolationError`) — no silent degraded mode. CPU-bound loops are interruptible by `kill` only via `worker.terminate()` when no signal handler is registered. ### Async context Status: proposed 2026-11-03, amended 2026-11-04. Stage 1 built 2026-11-04; stage 2 not started. `AsyncLocalStorage` follows a program's async work the way it does in Node 24, so a store set with `run()` is still there after an `await`, in a timer, and in an I/O callback. Frameworks that keep per-request state this way (Hono's context storage, Next.js, OpenTelemetry) then work unmodified. So does `node:test`, which uses the same mechanism to put a test registered after an `await` under its async `describe()`. **The contract is the WinterTC portable subset.** WinterTC's [portable subset of `AsyncLocalStorage`](https://github.com/WinterTC55/proposal-minimum-common-api/blob/main/asynclocalstorage.md) defines the API and the propagation rules. Workers, Deno, Bun and Node implement it, and it stays forward-compatible with TC39 `AsyncContext` (Stage 2). The subset is `run`, `exit` and `getStore`, and `AsyncResource`'s constructor, `runInAsyncScope`, `bind` and static `bind`. The kernel keeps Node's extras (`AsyncLocalStorage.bind` and `snapshot`, `enterWith`, `disable`) because Node code calls them. Neither class goes on `globalThis`. **Node 24.16.0 is the oracle, and it already uses the subset's model.** Node 24 runs `AsyncLocalStorage` on AsyncContextFrame by default (the flag is `--no-async-context-frame`). Probes under the pinned Node give the subset's answers, so goldens recorded under Node test the subset: | Case | The store seen | Stage | |---|---|---| | `.then` attached in frame B to a promise created in frame A | B | 1 | | EventEmitter listener added in A, emitted in B | B (emit is synchronous) | 1 | | `setTimeout`, `setImmediate`, `nextTick`, `queueMicrotask`, `fs` callback | the frame they were scheduled in | 1 | | `http` server request handler and `req` events | the frame `listen()` ran in | 1 | | `http.request` response callback and `res` events | the frame `request()` ran in | 1 | | `unhandledRejection` for a promise created in A, rejected in B | B | open | | After `await`, including `await fetch()` | the async function's frame | 2 | | `catch` after a rejected `await` | the async function's frame | 2 | | An async generator's body, and after each `yield` | the frame of the `next()` call that resumed it | 2 | | A `for await` body and the code after the loop | the generator-or-function's current frame | 2 | | A module's top level after a top-level `await` | the root frame | 2 | **One frame per worker.** The frame is the existing immutable map in `src/runtime/async_hooks.ts`. Two internal calls drive it: `capture()` returns the current frame, and `enter(frame)` makes a frame current and returns the previous one. **Stage 1: propagation the kernel controls, with no parser.** 1. **Callbacks the kernel schedules capture when scheduled.** These are timers, immediates, `nextTick`, `queueMicrotask`, and the callbacks of syscall completions (`fs` and the other callback APIs). The kernel implements all of them. `queueMicrotask` is the one global the kernel installs, as Node does: its own is a plain JS function (`lib/internal/process/task_queues.js`), not an engine intrinsic, so the kernel installs its own with Node's name, length and validation, wired to the frame instead of `AsyncContextFrame`. The `node:test` timer wrap that 3415f68 removed did replace a global, and broke `util.promisify(setTimeout)`. 2. **Handles the kernel creates capture when created.** Servers, sockets, client requests, child processes, watchers and the kernel's streams. Events emitted from their completions run in that frame, which gives the two `http` rows above. 3. **Promise reactions capture at `then()`.** `Promise.prototype.then` becomes a `Proxy` over the native `then` whose `apply` wraps both callbacks in the frame current at the call. `catch`, `finally` and the combinators call `then`, so they inherit it. The Proxy goes on the first time a worker leaves the root frame (`run()` or `enterWith()`). Before that every frame is the root, so every `then()` would capture the root anyway, and a worker that never uses `AsyncLocalStorage` keeps the engine's own `then`. Every stage 1 entry point enters its frame and restores the previous one when the callback returns, so the frame is the root again between tasks. After an `await`, a stage 1 kernel sees the root frame, as it does today. It never sees another task's frame. Letting jobs leave their frame set would carry a frame across `await`, but only by accident: a connection pool settles a waiting request's promise from inside another request's `release()`, and the waiter would see that other request's store. **Stage 2: `await` and `yield`, through a source transform.** The engine gives no hook for native `await`, and `AsyncContext` is a Stage 2 proposal the kernel cannot rely on. In every async function, async arrow, async method, async generator, and a module's top level: - the body starts with `const $f = $nib.c();`, a box holding the frame current at entry; - each `await E` becomes `$nib.r($f, await E)`, where `r` enters the boxed frame and returns its second argument; - each `yield E` in an async generator becomes `$nib.y($f, yield E)`, where `y` stores the current frame in the box, because Node resumes a generator in the frame of the `next()` call that resumed it; - each `catch` and `finally` block whose nearest enclosing function is this one starts with `$nib.r($f);`, because a rejected `await` resumes by throwing and `r` never runs; - a `for await` body and the statement after the loop start with `$nib.r($f);`. Between its awaits, an async function's frame changes only through the generator rule above, because `run()` only changes it for a synchronous call. So restoring the box is exact. The restore runs in the resumed code, so it does not matter who settled the promise: the browser (`fetch`), a kernel completion, or code in another frame. That is what stops one request's store from leaking into another's continuation. The transform adds no `await`, promise or tick, and `r` and `y` are synchronous, so callback and microtask order are unchanged. It is not the source-level async conversion rejected under [Alternatives considered](#alternatives-considered): no synchronous code becomes async. `r` leaves its frame current, so stage 2 also adds a root reset. After a microtask drain in which any `r` ran, one microtask queued at the end of the queue enters the root frame. A callback the browser dispatches directly to guest code, such as the guest's own listener on a `WebSocket`, `MessagePort` or IndexedDB request, then starts in the root frame instead of the last continuation's. The extra microtask runs after every guest microtask in that drain, so it changes no guest-visible order. **Where the transform runs.** On the kernel side, for both module systems. ESM sources pass through `src/kernel/transform.ts` already. CJS sources are compiled in the runtime (`compileCjs`), so the kernel transforms the text it returns for a `require` read, behind the same cache. A regex pre-scan skips a file with no `async` or `await` token. Results are cached by content hash, so a file in an npm package is parsed once per store, not once per process. The parser loads lazily, on the first file that needs it, as swc.wasm does for TypeScript. A per-process option turns the transform off. **What it costs, measured 2026-11-04.** Under Node on an Apple M1, over the demo apps' `node_modules`. Not yet measured inside the kernel. | | Bytes, min / brotli | Vite demo: 94 of 1467 files, 13.4 of 17.5 MB need a parse | Express + Hono: 165 of 593 files, 0.8 of 1.9 MB | |---|---|---|---| | Regex pre-scan | none | 3 ms | under 1 ms | | meriyah 6 | 119 KB / 29 KB | 270 ms | 45 ms | | oxc parser (wasm) | 737 KB / 195 KB | 341 ms | 56 ms | | acorn 8 | 122 KB / 29 KB | 554 ms | 80 ms | For scale: `runtime.js` is 446 KB / 126 KB and `kernel.js` is 314 KB / 82 KB. Those parse times cover every matching file in the tree, and a process pays only for the files it loads, once per content hash. A purpose-built scanner (below) could replace the parser. **What the transform needs from a parser.** It needs no AST, scope analysis, code generation or source map. It needs these offsets, in the coordinates of the text the kernel serves (after type stripping, which keeps positions): - each async function-like (declaration, expression, arrow, method, generator), and the module's top level when it contains a top-level `await`: where its body's statements begin, after `{` and after any directive prologue such as `"use strict"`. For an arrow with an expression body, the start and end of that expression, so it can become `{ const $f = $nib.c(); return (…); }`; - each `await`: the offset of the keyword and the end of its operand, the UnaryExpression after it, so that `await a + b` wraps only `await a`; - each `yield` and `yield*` in an async generator: the keyword and the end of its AssignmentExpression operand, or the keyword's end when it has none; - each `catch` and `finally` block whose nearest enclosing function is async: the offset just inside its `{`. A block inside a nested synchronous function is excluded, because that function can run later in another frame; - each `for await`: its body's start and end, and whether the body is a block, plus the statement's end; - which async function each of these belongs to; - whether `await using` declarations occur, and if so each enclosing block's end, since their disposal awaits when the block exits. (Whether the kernel's engine and Node 24.16.0 support `await using` without a flag is unchecked.) It must get `await` and `async` right where they are identifiers (`await` in a sloppy script, `async` as a name or a call), and it must tokenize regex literals, nested template literals and class bodies correctly. It reports failure instead of returning partial offsets, and the kernel then serves the file untransformed. Insertions never contain a newline, so line numbers are unchanged and the kernel can build the column table from the insertions alone. To beat meriyah, it needs to exceed about 50 MB/s on the Vite tree above, and to stay well under 29 KB brotli. **Costs accepted for stage 2.** - Columns shift on a line that holds an `await` or `yield`, and line numbers do not. The kernel's stack formatting maps frames in transformed files back through a per-file column table. A guest's own `Error.prepareStackTrace` sees the shifted columns. - `fn.toString()` of an async function shows the inserted calls. - The parse time above, on first load of each file content. Once a worker uses `AsyncLocalStorage`, stage 1 makes `Promise.prototype.then` a `Proxy`. Its identity then differs from the engine's, a reference to `then` saved earlier bypasses it, and `Function.prototype.toString` gives `function () { [native code] }` without the name, as V8 does for any callable Proxy. Each `then` pays a wrapper call: measured 2026-11-04, in the kernel, on a 1e6 `Promise.resolve().then(f)` loop with the root frame current (so every call takes the fast path below), about 3x a bare native `then` — above the ~1.5x this design hoped for. The fast path already skips wrapping onFulfilled/onRejected when the captured frame is the (empty) root frame, and calls the native `then` with `fn.call(thisArg, a, b)` rather than `fn.apply`/`Reflect.apply` for the 0–2-argument case `then()` is almost always called with (a bare-Proxy microbenchmark outside the kernel showed `.call` beating `.apply` by about a third); neither closed the gap to 1.5x inside the kernel, so the remaining cost looks like the Proxy mechanism itself (V8's Proxy `apply` trap dispatch, unavoidable without giving up `Function.prototype.toString`'s native look). `setTimeout`/`setImmediate`/`nextTick`, which wrap a single stored callback once per schedule rather than proxying every call, show no equivalent regression. **Tests.** Goldens recorded under Node 24.16.0 for every row of the table above, each in the stage that delivers it. Also: two interleaved `run()` chains that each `await fetch()` and must not see each other's store, `catch` and `finally` after a rejected `await`, `AsyncResource.bind` on an EventEmitter listener, and `node:test` registering `it()` after an `await` inside an async `describe()`. That last test, in stage 2, deletes `currentBuildingParent`'s stack in `src/runtime/test_runner/test.ts`. The existing tick-order goldens (`loop-*`, `builtin-stream-ticks`, `builtin-test-order`) must pass unchanged in both stages. **Still refused.** `async_hooks.createHook`, `executionAsyncResource` and the rest of the resource-lifecycle API. They report resource creation and destruction, which this design does not track. **Open questions.** - Which parser: meriyah, or a purpose-built scanner meeting the contract above. Stage 2 starts by measuring both inside the kernel on the Vite and Hono demos. - Whether the transform defaults on for `node` processes. - Should `web` processes (D2) run the transform? They reach `node:async_hooks` only on purpose, but a process cannot know before its modules load that it will. A global `AsyncContext` for `web` processes waits for TC39. - `unhandledRejection` should see the frame of the `reject()` call. The kernel learns of an unhandled rejection from the browser's `unhandledrejection` event, which carries no frame. Matching Node needs the frame recorded at `reject()`, which means wrapping the `Promise` constructor's resolving functions. The open question is whether that is worth it, or whether the handler should run in the root frame. - What `enterWith` means here. Node documents it as entering the store for the rest of the current synchronous execution and keeping it through the async work scheduled after that. In the frame model, it replaces the current frame until something enters another one. ### Processes, threads, signals Kernel PCB: `Process{ pid, ppid, threads: Map, fds, cwd, env, handled: SignalSet }`; a Thread is one Worker, via a `WorkerFactory` interface (nested workers default, host-proxied fallback). The kernel keeps one runtime worker spawned ahead of need, idle until `init`, so the next process or thread starts with runtime.js already evaluated; only a child process or thread taking it refills it, so a lone top-level process leaves no idle worker. A process has a runtime (`exec({ runtime })`, VISION D2): `node`, the default, installs Node's globals and loads the entry as Node does; `web` installs none of them, gives number-returning timers, and runs only an ES module entry (`ERR_NIB_WEB_ENTRY_NOT_ESM` otherwise). The kernel stamps the Proc's runtime on every thread's `init`, and a spawned child is always `node`. `spawn/fork/exec*` target `node` or a VFS file with a `#!/usr/bin/env node` shebang; stdio is `pipe` (kernel ring) / `inherit` (shared OpenFile, shared offset) / `ignore`; `fork` adds a transferred IPC MessageChannel (`serialization: 'advanced'` = structuredClone). `worker_threads` spawns a Thread in the same Pid sharing fds and cwd. `process.exit` runs exit listeners, flushes stdio, then the never-replying `exit` syscall terminates the worker (a throw would be catchable). Signals: `SIGKILL` or unhandled → terminate; otherwise posted to the thread's port and delivered as a poll-phase completion; signaled exit reports `{ code: 128+n, signal }`. ## Build order Each step ends in a real-browser acceptance proof (Playwright), in the C3/C2 discipline. The kernel and runtime pair run in-memory from step 1; OPFS, SW, and WASM arrive when they're needed. Progress notes: step 1 ✅. Resolver v2 (package.json `exports`/`main`, node_modules walk, self-reference, nearest-`type` format) landed early with the demo — it is shared by CJS and the future ESM path. A `.js`/`.ts` whose nearest package.json has no `type` (or that has none) is syntax-detected as Node 22+ does: import/export or `import.meta` (es-module-lexer), or code that fails only inside the CJS wrapper (top-level await, a top-level `const require`) makes it ESM, with Node's `MODULE_TYPELESS_PACKAGE_JSON` warning once per package.json and none under node_modules or without a package.json. The warning is emitted when the detected module evaluates (on the next tick), not while the graph loads as in Node, so output from modules that run before it can come first. The SW routing probe (open question below) **PASSED in Chromium 156**: a controlling Service Worker intercepts module imports inside workers — **both page-spawned and nested workers** (kernel→runtime nesting is the shape we actually use). ESM (step 6) has therefore landed before steps 2–5: guest ESM executes via the browser's native module linker over the SW virtual origin (`/~/vfs|builtin|cjs`), with specifier rewriting done by the kernel (es-module-lexer) and CJS↔ESM interop via facade modules sharing `require.cache`. Lessons from the ESM slice, recorded as invariants: - **SW before kernel**: nested workers inherit their creator's controller, so the SW must be registered and controlling *before the kernel worker is constructed* (host `boot()` order). - **Metadata versioning must be stable under read paths**: `MemoryFS.open()` used to bump mtime on reads, which with version-keyed virtual URLs caused an infinite refetch loop on cyclic module graphs. Version inputs may only change on real mutations. - **Each host page owns its `/~/` URLs, and the SW revives its bridges after a stop.** Every virtual URL names its host page: `/~/@/{vfs,builtin,cjs,http}/…`, where `` is a 16-hex-digit token `boot()` draws once per page. The kernel reads it from its script URL (`nib-host`) and hands it to each runtime in `init`, so every module URL either builds carries it. The SW keeps `bridges: Map`: two pages on one origin each reach their own runtime, and an app URL opened in a new tab reaches its host while that page is open. The plan was to resolve owners over a client tree (page → kernel worker → runtime workers, learned from worker-script fetches) and keep module URLs token-free. A probe ruled that out: Chromium 156 gives every fetch from a *nested* dedicated worker an empty `clientId`, module imports and `fetch()` alike, while pages and direct workers carry theirs. Runtime code runs in nested workers, so only the URL can name the owner. App clients still map by client (`clientApps`, next item), since they are documents and direct workers. A token with no open page answers 502 for http and 404 for modules, both naming the token and both with COEP/CORP. A closed page's port drops messages silently (Chromium 156 has no MessagePort `close` event), so each relay asks the Clients API about the registering window in parallel, and a gone window retires its token at once; registration prunes the same way. Revival: the SW's top-level script posts `{ t: "rebridge" }` to every window client, and so does a fetch for a token the SW does not know (at most one broadcast per 3 s). A new version broadcasts again on activate, past that limit: it inherits the old version's pages then, and its top-level broadcast ran while it was installing and controlled none. A host answers with a new MessageChannel under the same token, wired to the same kernel, and the waiting fetch proceeds; it gives up after 3 s. Bridge ids start at `Date.now() * 1024`, so a kernel's late reply to a stopped SW's request cannot resolve a new request that reused its id. Limits: requests in flight when the browser stops the SW are lost with it, and their late replies are dropped. A request with no token, no prefixed referrer and an unknown `clientId` (any app client after a restart until its document makes a request, or a worker nested inside a guest app) goes to the network. Only a live SW remembers retired tokens, so after a restart a closed page's URL waits the full 3 s before its 502. - **Module-content failures travel as named-code stubs**: the `loadModule` syscall has no error slot beyond errno and the SW channel has none at all, so unstrippable TS / unlexable sources are served as stub source that throws the named error (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`, `ERR_NIB_TS_ENGINE`) at evaluation — the failure surfaces at the right place with zero protocol churn. - **The ESM lexer must be es-module-lexer's main build, not `/js`**: the asm.js build misclassifies `import.meta` as a dynamic import in common shapes (latent bug the TS demo exposed). - **The TS extension contract is Node's verified one**: extensions are mandatory (`require()` does NOT probe `.ts` — verified against Node v24); `.tsx` fails with `ERR_UNKNOWN_FILE_EXTENSION`; enums fail with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`; node_modules `.ts` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. - **Guest http servers route through a kernel port table** (`Map`, next to `procs`): the kernel is the process-table owner, so it is the only routing authority for `/~/@/http//`; `listen`/`unlisten` are syscalls and `finishProc` frees a dead proc's ports and answers its in-flight requests with 502. The SW's bridge id passes through unchanged SW → host → kernel → runtime and back, so no hop keeps an id-remapping map for http. Bodies are buffered whole; every response carries COEP/CORP because the page is COEP require-corp. - **Guest-app requests route by client, not by URL prefix** (`src/sw/route.ts`, one pure function over request facts returning a tagged `Route`, plus the SW's `clientPorts` `Map`). Real apps use absolute paths (`/app.js`, `fetch("/api")`, ``, a guest Vite's `/@vite/client`), which escape `/~/@/http//`. `/~/` stays the runtime's only reserved namespace: adopting Vite's URL forms (`/@fs/`, `/@id/`) would collide with a guest Vite serving them itself. A `/~/@/http//` navigation or worker-script request adopts its `resultingClientId`; later same-origin requests from a mapped client go to that port, and a worker spawned by a mapped client inherits the port even from an unprefixed script URL. Navigations carry no `clientId`, so a navigation whose referrer is inside the prefix gets a 307 back into it (method and body survive, and the new document's own navigations stay attributable). The table is in memory; after an idle kill it is re-learned from referrers inside the prefix. Limits: a `rel=noreferrer` or `Referrer-Policy: no-referrer` navigation escapes to the real server, and after a SW restart a client whose requests all carry unprefixed referrers (a worker, a stylesheet's `@import`) is not re-learned until its document makes a request. Verified in Chromium: iframe navigations and worker scripts both carry `resultingClientId`, worker scripts also carry the creator's `clientId`, and `clients.matchAll({type: "all"})` lists dedicated workers, so pruning dead ids on adopt is safe. - **Real npm apps arrive as snapshots** (`src/node/snapshot.ts`, CLI `scripts/snapshot.mjs`), a binary v3 format (`.snap`): 4-byte magic `NIBS`, a little-endian u32 header length, a UTF-8 JSON header `{ "version": 3, "files": [[path, size, mtimeMs], ...] }`, then every file's raw bytes concatenated in header order — no text/binary split, no base64. App-owned files are kept whatever their extension (static assets); inside node_modules only `package.json` files and `.js .mjs .cjs .json .ts .mts .cts` sources are kept, minus declarations and source maps. Every kept file's `mtimeMs` comes from `fs.statSync`. `runtime.mountSnapshot(buffer)` parses the header and builds seed entries whose `data` are `Uint8Array` views into `buffer` (no copies), then transfers `buffer` to the kernel in the same postMessage, and seeds through the kernel's `seed` message, whose `{ path, data, mtimeMs? }` entries let MemoryFS keep the recorded mtime; it refuses any other version (and a bad magic), and v1/v2 are gone because nothing produces them. `npm run fixtures` installs each `demo/apps/` from its lockfile (skipped when the hidden lockfile matches it, less the optionals the host's os/cpu exclude, which npm leaves out of it) and writes `dist/fixtures/.snap`. The first fixture, Hono 4.13.13 with @hono/node-server 2.1.5, runs unmodified. - **Static assets have fixed mtimes.** ETags and Last-Modified derive from size and mtime, so `scripts/fixtures.mjs` and the Node oracle (`scripts/golden-http.mjs`) both set every file under `demo/apps//public` to `2024-01-01T00:00:00.000Z` (`scripts/fixed-mtime.mjs`) before snapshotting or starting the app. The recorded headers are then the same on every clone. - **The builtin set is one table.** `BUILTIN_EXPORT_NAMES` (shared/) is the kernel's resolvable set and the facade's export list; the runtime registry is a factory table typed against its keys, so a resolvable builtin always has a module. A spec checks every listed name is a defined export. Builtins load once per worker. - **A request's socket is a facade over the SW bridge, not a TCP socket.** One `VirtualSocket` per request carries `encrypted: false`, `remoteAddress` 127.0.0.1, an ephemeral `remotePort`, the listening port, and a lifecycle: `destroy()` before the response ends the exchange with a 502 (`ERR_NIB_SOCKET_DESTROYED`), and after the response it closes the exchange (one request per connection, no keep-alive). Browsers never give a Service Worker the Host header, so requests without one get `host: localhost:`. - **A page's WebSocket reaches its guest server as bytes, not messages.** A Service Worker cannot intercept WebSockets, and guests speak the protocol themselves (`ws`, socket.io and Vite's HMR server all take the raw socket from `server.on("upgrade", (req, socket, head))`), so the transport is a byte stream and the page is the RFC 6455 client. Relaying messages would mean a second WebSocket implementation inside the runtime and would break any library that reads the socket itself. A connection is one MessagePort: the shim posts `{ t: "connect", socket, path, page }` to the controlling SW, which finds the page's app (the URL's own `/~/@/http//` prefix, else `clientApps`, else the page URL, as route.ts re-learns) and transfers the port over that host's bridge; the host transfers it to the kernel, and the kernel, after a port-table lookup, to the runtime owning the port. Bytes (`Uint8Array`, `null` for end) then flow page ↔ runtime worker and no hop keeps a part in them, so a stopped SW does not affect an open socket. With no listener the kernel writes an HTTP 502 and ends. A terminated worker cannot close its ports and Chromium 156 has no MessagePort close event, so the kernel first sends the page a watch port and posts `{ t: "exited" }` on it when the owning process ends; the page sees 1006. The runtime parses the request head into an `IncomingMessage`, keeps the rest as `head`, and wraps the port in a `PortSocket` (a `Duplex`, half-open as Node's http sockets are) that holds the loop until it closes. With no `'upgrade'` listener the request goes to `'request'` as a plain request, as Node v24 does, and the connection closes after the response. Injection: a guest response to a navigation whose type is `text/html` (or absent with a body that sniffs as HTML) and that is not content-encoded gets `` as the first child of ``, or before the first tag when the document has no `` tag, with `content-length` recomputed; fetches of HTML are left alone. The shim is a classic script, so it runs before module and classic app scripts alike, and the SW serves it from a string bundled into `dist/runtime/sw.js`. Limits: WebSockets opened from entry points that are not HTML documents (a worker inside the app, an `about:blank` or `srcdoc` frame, a document written by `document.write`) use the native WebSocket and reach the real origin; a guest page whose CSP forbids same-origin scripts blocks the shim; a gzipped document goes unshimmed; HttpOnly cookies never reach the guest, since the shim sends `document.cookie`. A page that vanishes without `pagehide` leaves its socket open in the guest until the process exits. - **`node:http2` is refused, not faked.** Its classes exist so imports link and `instanceof` is always false (adapters branch on `Http2ServerRequest`); every factory throws `ERR_NIB_UNSUPPORTED` naming http2. - **The streams subset is Readable, Writable, Duplex and Transform.** `node:stream` exports the legacy `Stream` constructor and a `Readable` with flowing and paused modes, `read(size)`, `setEncoding`, Node's data/end/close order with autoDestroy, `destroy(err)`, the `readable*` state getters, async iteration, and `Readable.toWeb`/`fromWeb`. `IncomingMessage` is a `Readable` whose whole body is pushed on arrival, so late `'data'` listeners get it. `pipe()`/`unpipe()` write into any writable-shaped emitter with Node's drain, end and unpipe rules. Node's writable state machine is one `WritableState` shared by `Writable`, `Duplex` (a Readable that borrows Writable's methods; `instanceof Writable` holds, as in Node) and `Transform`; `_writableState` and a `_readableState` view expose the fields npm code reads (ws reads `finished`, `errorEmitted`, `needDrain`, `endEmitted`, `length`). `ServerResponse` buffers until `end()`: `write()` always returns true and a streamed body (Hono's `ReadableStream`) is collected and sent whole. - **Events keep Node's shape.** `EventEmitter` is a function constructor with listeners on `this._events`, because npm code calls `EventEmitter.call(this)` and mixes the prototype into functions (Express's `app`). Class-based builtins (`Readable`, `ServerResponse`) use private fields and cannot be `.call`ed. - **Stream and http callbacks run on the nextTick queue** (`src/runtime/ticks.ts`, bound to the loop at boot). A tick queued from a microtask schedules its own drain. - **One timer model, in the loop** (`src/runtime/loop.ts`). Timers (`{ id, at, seq, delay, repeat, ref }`), immediates and poll completions are the loop's own; the guest's `setTimeout`/`setInterval`/`setImmediate` (`src/runtime/timers.ts`) return Node-shaped `Timeout`/`Immediate` objects over those records, so `ref()`/`unref()` decide liveness and `clearTimeout` takes the object or its numeric id. `node:timers` and `node:timers/promises` reuse the same functions. The loop captures the browser's `setTimeout`/`clearTimeout` at module load, before `src/runtime/globals.ts` replaces the globals, and arms one native timer for the earliest expiry. Phases run timers → poll → check on snapshots taken at phase entry, **one guest callback per MessageChannel task**, so microtasks drain between callbacks. Ticks queued inside a callback drain right after it; ticks queued from a microtask drain in the next step, after the microtask queue. The CJS entry runs as a loop callback (`runMain`), which gives Node's CJS `then(a); nextTick(b)` → `b, a`. - **`process` is a per-worker EventEmitter** (`src/runtime/process.ts`), installed as a global before the entry runs in both module systems, passed to the CJS wrapper, and served as `node:process`. Lifecycle: when the loop is idle after the entry, it emits `beforeExit`; if that schedules nothing, the next idle step exits, and if it does, `beforeExit` fires again at the next idle point. `exit` is emitted once, from idle exit, `process.exit()` and the fatal path, with `process.exitCode ?? 0`, which an exit listener may still change. `process.exit()` blocks on `Atomics.wait` after the never-replying `exit` syscall, so it does not return into guest code. `uncaughtException` listeners take throws from loop callbacks, errors that escape to the worker and the entry itself; `unhandledRejection` listeners take rejections, and without one a rejection goes to `uncaughtException` with origin `unhandledRejection`, as in Node. With no listener the fatal report is unchanged. - **The version strings are a compat target, not a claim to be Node.** `process.version` is `v24.16.0` (the Node the goldens were recorded against), `versions` is `{ node, nib }`, `release.name` is `node`, platform/arch are `linux`/`x64`. Packages branch on these, so they name the Node line whose behavior this runtime matches. - **Dynamic `import()` is rewritten, never native.** A native `import()` resolves against the importing script's URL: the runtime worker's for CJS (compiled with `new Function`) and a `/~/vfs/` URL for ESM, so relative specifiers fetched the wrong URL, and nothing held the loop while they failed. The kernel replaces each `import` keyword the lexer marks as dynamic (ESM transform and CJS `loadModule` alike) with `nib.runtime.importFrom()`, which resolves the call's real specifier at run time and holds the loop until the import settles. - **Express 5 runs unmodified, checked against real Node's answers.** `scripts/golden-http.mjs` starts an app under real `node` with `NODE_ENV=production`, replays a requests file and prints status, content-type, ETag, content-length, x-powered-by and body; `tests/express.spec.ts` asserts the runtime's answers equal those literals. Matching ETags prove the SHA-1 is right. Real failures drove the order: `tty`, `util`, `path`, `zlib`, then a resolver bug (an `exports` object of bare conditions, `{ types, default }`, is the `"."` target), `url`, `querystring`, `crypto`, `net`, then three request-time gaps: a bridged request had no Content-Length (a Service Worker never sees it, so it is now restored from the body, as the Fetch standard sets it), `req.unpipe()` was missing, and `string_decoder`. Last, Buffer's statics were not enumerable, so safer-buffer's for-in copy lacked `isBuffer`. Express's `Object.setPrototypeOf(req, app.request)` needed nothing: private fields live on the object, so they survive the swap. Builtins added from the brief rather than a failure: `async_hooks`, `https`. - **Builtin fidelity is recorded, not asserted by hand.** Each new builtin has a `tests/fixtures/guests/builtin-*` guest whose Node output is its golden (`tests/builtins-golden.spec.ts`). `util.inspect` follows Node's algorithm (compact=3 line joining at breakLength 80, array column grouping, circular refs, error stacks with extra keys); it cannot observe Promise state or Proxy targets, so a Promise prints `Promise { }`. `console` formats through `util.formatWithOptions`, as Node does for a stream that is not a TTY. The runtime bundle keeps names (esbuild `keepNames`), because guests print builtin class names. - **Synchronous hashing is a TypeScript stopgap for the residue.** `createHash`, `createHmac` and `crypto.hash` cover md5, sha1, sha256 and sha512 in `src/runtime/digest.ts`, with round constants derived from their definitions (prime roots, sines). The precedence table still routes sync digests to the WASM residue (step 5); it can replace `digest.ts` for speed with no change in results. Randomness and `webcrypto` are the browser's. - **Refused, not faked.** `node:zlib`'s `*Sync` forms (they wait for the residue), brotli and zstd in every form (no CompressionStream format), `node:https`, `net` sockets and servers, `tty` streams, ciphers, signing and key derivation in `crypto`, `path.win32`, `util.isDeepStrictEqual` and `async_hooks.createHook` load and throw `ERR_NIB_UNSUPPORTED` naming the module (`src/runtime/refuse.ts`). An unknown digest throws Node's own "Digest method not supported"; one Node has and this runtime lacks (sha384) refuses. `node:test`'s `run()` is one function with a partial refusal, not a wholesale one: it validates every option exactly like Node's regardless of what follows, and only after that refuses `isolation: 'process'` (the default — Node spawns `node --test-reporter=...` children and reads back the v8-serializer reporter's bytes; this kernel has neither a CLI flag parser nor `v8.Serializer`), `coverage: true` (needs the inspector) and `watch: true` (needs a `FilesWatcher` over `fs.watch`'s recursive/rename semantics, not implemented). `run({ isolation: 'none' })` with neither of the other two set actually runs, in this process — the function itself is never `markRefused()` (`src/runtime/refuse.ts`), so the surface walker sees a plain function, same as Node's, and `compat/divergences.json` carries no entry for it; the three refusals are instead asserted directly against the kernel in `tests/builtins.spec.ts`, since real Node does not throw for any of them (it spawns children, enables coverage, or starts a watcher) and so none of the three can be golden'd (`src/runtime/test_runner/runner.ts`). - **AsyncLocalStorage follows the kernel's own async work, not yet `await`.** A context (the "frame") is an immutable map from each store to its value; `run`/`exit` swap it for one synchronous call, and `AsyncResource` (`bind`, `runInAsyncScope`) and `AsyncLocalStorage.bind`/`snapshot` carry it to a later callback, as before. Stage 1 of [Async context](#async-context) is built: it also follows every callback the kernel itself schedules (timers, `nextTick`, `queueMicrotask`, syscall completions), every handle the kernel creates (servers, sockets, child processes, watchers, workers, readline), and every `Promise.prototype.then` reaction. It does not yet follow a native `await` or `yield` (stage 2, not started) or anything the engine alone drives without the kernel in the loop. - **CJS stack lines are the guest's own.** The CJS wrapper opens on the body's first line and is compiled with an indirect `eval` (`compileCjs` in `src/runtime/modules.ts`), as Node does. `new Function` prepended two header lines and shifted every frame by two (`loop-stackline.cjs` golden). - **Static files match Node through each framework's own middleware.** Express's `express.static` (send) and @hono/node-server's `serveStatic` serve `public/` unmodified; the oracle records Last-Modified, Cache-Control, Accept-Ranges and Content-Range too, stores non-text bodies as base64, and sends requests over `node:http`, because `fetch()` adds `cache-control: no-cache` to a conditional request and turns Node's 304 into a 200. Gaps in the order they were hit: `fs.stat is not a function` (send's index probe), then `fs.createReadStream is not a function` (send streaming the file), then Hono's import of `hono/utils/mime`, which exposed that `exports` subpath patterns never expanded their `*` (the capture came from the request, not the matched key). `Stats` with Date fields came from reading send and etag rather than a failure. `node:fs` (`src/runtime/fs.ts`) now has: callback forms `stat lstat fstat open read close readFile readdir access exists realpath(.native)`; `fs.promises`/`node:fs/promises` with `access open readdir lstat stat realpath readFile` and a `FileHandle` (`read readFile stat close`); sync forms `statSync lstatSync fstatSync existsSync accessSync openSync readSync closeSync realpathSync(.native) readFileSync writeFileSync readdirSync unlinkSync mkdirSync rmdirSync renameSync`; `createReadStream`/`ReadStream`, `Stats`, `Dirent` and Linux `constants`. Callback results arrive through `ctx.submit`, an async syscall whose reply runs as a poll-phase loop callback, so an immediate queued in one runs before a timer queued with it, as in Node. Each composite op (readFile, realpath) is written once against an `Io` and runs on the sync mailbox or the async port. Errors follow Node's uvException: `ENOENT: no such file or directory, stat 'x'`, negative `errno`, own keys `errno, code, syscall, path`, the Node syscall name (`lstat`, `access`, `scandir`) and the path as the caller wrote it. `Stats` has Node's own numeric fields in Node's order and prototype Date accessors; fields the VFS does not track are constants (`dev` 1, `nlink` 1, `uid`/`gid` 0, `rdev` 0, `blksize` 4096, `blocks` = ceil(size / 512)), and atime, ctime and birthtime report the mtime. File mode stays the kernel's `0o100755`. The kernel gained `fstat` and numbers fds from 3. - **A narrower-scoped Service Worker shadows the runtime's.** Chrome gives a page to the registration with the longest matching scope. The old SW probe registered `demo/sw.js` with scope `/demo/`, so in any browser that had opened it, that worker controlled every demo page, slopbox included: every `/~/` module fetch fell through to the static server and 404ed, and the supervisor crash-looped ("Failed to fetch dynamically imported module"). Fresh test profiles never saw it. The probe now lives in `/demo/sw-probe/` with that scope; `demo/sw.js` is a tombstone that a browser fetches on its next navigation under `/demo/`, which unregisters itself and reloads its pages; and `boot()` throws `ServiceWorkerShadowedError`, naming the controlling script, whenever a worker other than the runtime's controls the page. "The runtime's" means served from `dist/runtime/sw.js`'s path or answering `{ t: "ping" }` with a pong within 1 s, so a site worker that `importScripts()` the runtime's (the fix `ServiceWorkerConflictError` names) counts as the runtime's; `dist/runtime/sw.js` is built as an IIFE so it leaks no globals into that worker. - **Loop goldens are recorded from Node, not written by hand.** Each `tests/fixtures/guests/loop-*` guest prints one line per event; `node scripts/golden.mjs ` prints Node's stdout, stderr and exit code, and `tests/loop-golden.spec.ts` asserts the runtime's output equals that literal. - **The harness (`/demo/harness.html`) is the UI contract** for the host fs facade: its selector map (tree rows by `data-path`, tabs with dirty dots, `#inline-row`, `#confirm-row`, `#entry`, `#status`, status bar) is the page's de-facto public interface for tests and humans. Two behaviors worth carrying forward: tree rendering only descends into *expanded* dirs, so "row missing" is disambiguated (`deleted` clears the editor; `hidden by collapse` preserves selection) — the same distinction any future fs.watch consumer will need; and per-tab `{saved, buffer}` state means tab switches never touch the disk, Save writes the active tab, and rename retargets open tabs in place. - **slopbox (`examples/slopbox`) runs snapshot apps on the public host API only.** Switching apps is a page load (`?app=`), not a re-boot: one page owns one runtime, so a fresh page gets a clean kernel, port table and VFS. Apps choose their own port, so the host gained `runtime.onListening(listener(port, url))` and `runtime.onUnlistening(listener(port))`. We chose an edge pair over one `onPorts(listener(ports))` snapshot because every consumer so far reacts to an edge (the supervisor's `starting → listening`, the port chip going stale), and a snapshot would make each of them diff. Ports already listening are replayed in a microtask so late subscribers are correct and can unsubscribe from their first call; `waitForPort` is now a filter over `onListening`. Restarting a live app needs to stop it, so `RunningProcess` gained `kill()`: the kernel finishes the process as it does a crash (worker terminated, ports freed, in-flight requests 502) and `exited` resolves 143 (128 + SIGTERM). The supervisor (`examples/slopbox/supervisor.js`) is a state machine with injected `spawn` and clock: `booting → starting → listening → crashed | exited`, `crashed → restarting → starting`. A non-zero exit restarts after 250 ms, 1 s, then 4 s; more than three crashes inside 30 s enters `failed` and stops. A manual Restart from any state kills the current run, clears the crash history and re-execs in the same runtime, whose VFS persists; the killed run's exit is ignored because the supervisor tracks the current run by identity. A gap found while building it: `listening` events do not name the owning process, so a stale run's late listen could be taken for the new run's. (ESM stack frames printing the virtual URL, also found here, is fixed: the transform binds `import.meta` on the guest's first line and appends a `file://` sourceURL, checked by the `loop-esmstack.mjs` golden.) - **The host sees the process table.** `runtime.onProcesses(listener(table))` replays the live table and calls again after every spawn, exit, listen and unlisten; each row is a `ProcessInfo` (`pid`, `ppid`, `argv` as the guest's `process.argv`, `cwd`, `ports`). Processes a guest spawns are listed too, so an IDE can show `npm run dev`'s server and the port it owns. Unlike the port edges above, this is a snapshot: a processes view re-renders a tree, and diffing edges would rebuild one anyway. The kernel posts `procStarted`/`procExited` and names the pid on `listening`/`unlistened`, which closes the gap above; the host keeps a mirror so subscribing needs no round trip. `runtime.kill(pid, signal)` signals any live process, and `proc.pid` finds a handle's row. - **Buffer's base64 and hex ride Uint8Array's native codecs.** `fromBase64`/`fromHex`/ `toBase64`/`toHex` are feature-detected at module load. The native decoders are strict where Node is lenient: base64 throws on a character outside the one alphabet it is given (so on mixed base64/base64url, junk, or non-ASCII whitespace), on data or extra `=` after padding, on partial padding (`"QQ="`) and on a dangling sextet; hex throws on any non-hex character, where Node keeps the pairs before it. Odd hex length is trimmed first, as Node drops the last nibble. Whenever they accept, the answer is Node's, so a throw falls back to the JS decoder. Strict inputs take the fast path, and `builtin-buffer-encodings.cjs` records both kinds. Recording it against Node also found that `Buffer.byteLength` for base64 and hex is a length estimate, not a decode, and that the old hex decoder accepted pairs `parseInt` half-reads (`"1g"`). - **`stream.Transform` is Node's writable state machine over the Readable** (`builtin-stream-transform.cjs`): writes queue while one is in flight, a write whose output fills the readable side holds its callback until the next `_read`, `end()` runs `_final` (by default `_flush` then EOF), `'prefinish'`, `'finish'` a tick later, and `'close'` once both sides are done. The Readable gained what that order needs: Node's `sync` flag (a push outside `_read` while flowing emits `'data'` on the caller's stack), `_read` before taking in `read()`, `maybeReadMore` after a push, and Node 22's 64 KiB default highWaterMark. - **zlib runs on CompressionStream/DecompressionStream** (`src/runtime/zlib.ts`): gzip, deflate and deflate-raw, async convenience forms calling back in the poll phase and Transform streams, both over one push-fed `Engine` per codec. Node's shape where the browser differs: Chromium's TypeError carries zlib's own message ("The compressed data was not valid: incorrect header check."), which becomes Node's `Error` with `Z_DATA_ERROR`/-3; "Compressed input was truncated." becomes "unexpected end of file", `Z_BUF_ERROR`/-5; another browser's wording keeps its message under `Z_DATA_ERROR`. Data after the end is ignored by Node for deflate/deflate-raw and starts another gzip member unless the next byte is 0x00, while the browser errors and drops that write's output, so the end is found again by bisecting the member's input (a prefix is truncated before the end, clean at it, junk after it) and decoding resumes from there; the member's compressed input is retained for that. unzip tells gzip from zlib by the first two bytes. Streams follow Node's ZlibBase: a write pushes its output only if it succeeds, the last write after `end()` finishes the codec, and `_final` returns at once, so `'finish'` precedes `'end'`. A codec write settles a task after the browser takes it, because the reader drains output in microtasks that can outrun the write's own settlement. `level`, `memLevel` and `strategy` are accepted and ignored (compressed bytes may differ from Node's, so goldens record round trips and decodes of Node-made bytes, never compressed bytes); `dictionary`, non-default `windowBits`, `flush()`, `params()` and `reset()` refuse. `crc32` is a JS table. body-parser's `Content-Encoding: gzip` path runs unmodified, checked against Node through the HTTP oracle (`bodyBase64` requests). - **Terminals are kernel objects; signals follow one rule.** A process spawned with pty stdio gets a kernel `Pty` (`src/kernel/pty.ts`): `{ id, cols, rows, termios, lineBuffer, foreground }`, with all three fds on it. The line discipline is a pure function from termios, the unfinished line and the input bytes to what fd 0 reads, what the master echoes and which signals to raise. Its scope is the Linux n_tty subset Node programs meet: canonical editing (DEL and BS erase, ^U kills the line, ^W a word, ^D on an empty line is EOF, a line is delivered on NL), ISIG (^C raises SIGINT, ^\ SIGQUIT, both flush the line), ECHO with caret forms, ICRNL on input and ONLCR on output. `setRawMode(true)` clears ICANON, ECHO, ISIG and ICRNL and keeps ONLCR, as libuv's raw mode does. IEXTEN, IXON and VMIN/VTIME are not modelled. On the host, `exec(entry, { pty: { cols, rows } })` returns a `PtyProcess`: `pty.write` takes keystrokes as a terminal emulator sends them, `pty.resize` raises SIGWINCH when the size changes, and `stdout` carries the master side already output-processed, so it can go straight to an emulator. A pipe-stdio process gains `proc.stdin`, a WritableStream whose close is EOF. Signal delivery has one rule, applied in one kernel function: SIGKILL always terminates the worker; a signal the guest listens for (`process.on("SIGINT")` reports the first and last listener through a sync `sigaction`) is set in the mailbox's pending-signal word and runs the listeners in the next poll phase; otherwise the signal's default action applies, so SIGINT, SIGTERM and SIGHUP end the process with 128 + n and report the signal, and SIGWINCH is ignored. A guest that never yields never takes a delivered signal, and SIGKILL is the way out. `RunningProcess.kill(signal = "SIGTERM")` goes through the same rule, and `proc.signal` settles with `exited`, so callers that read the exit number are unchanged. There is no job control. A stop signal (SIGTSTP, SIGTTIN, SIGTTOU without a listener, SIGSTOP always) is followed at once by SIGCONT, because nothing could ever continue the process. That is what readline's ^Z in raw mode needs: it raises SIGTSTP and waits for SIGCONT to take raw mode back. The line discipline drops ^Z in cooked mode, there are no process groups or background jobs, and the kernel never raises SIGTTIN or SIGTTOU. `node:readline` (createInterface, question, keypress parsing, the line editor) and the tty streams are ported from Node v24.16's `lib/`; `LICENSE-node` carries Node's MIT notice and lists every derived file. slopbox runs an app whose `package.json` says `"slopbox": { "kind": "cli" }` in a terminal pane on this API. `examples/slopbox/terminal.js` is the only module that touches the emulator (xterm today), so another xterm-compatible emulator is a one-file swap. xterm writes theme and cell-metric CSS into `