npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@threadedstack/wmd-web

v0.1.19

Published

Browser host for wmd's guest vm.wasm (Web Workers + shared memory + WASI preview1)

Readme

wmd-web

The browser host for wmd's guest vm.wasm. It is the same vm.wasm that wmd-run runs (sha1 5ac0e005134bfd198f01a92721324fe37bb93758), running in Web Workers on one shared WebAssembly.Memory, with a JavaScript WASI preview1 subset, wasi.thread-spawn over a Worker pool, and the wmd.* imports stubbed for interpreter mode.

Status: P-B1 passes in Chromium, Firefox and WebKit (Playwright 1.63). The arm64 kernel and the proof initramfs boot in interpreter mode, and every boot-gate regex of guest/vm/test/expected/boot-arm64.txt matches the page console, with exit 0. The amd64 kernel also passes in Chromium.

P-B2 passes in all three: the guest's own JIT (--mode jit) runs in the browser through the JIT door (src/host/jit.js). The same proof boot reaches PROOF-DONE 6.7x (Chromium), 8.1x (Firefox) and 7.3x (WebKit) sooner than P-B1, and the total run is within 3–11% of native wmd-run --mode jit wall time. See P-B2: the JIT door.

P-B3 passes in all three: wmd.chan_* run on shared-memory rings with a service Worker. A 9P round trip as a guest process sees it (fstat on a 9P mount) takes 89–187 µs (native wmd-run with wmd's Go 9P server over TCP: 104–207 µs), and the guest never waited for the host while writing 50 MiB into the control channel. See P-B3.

P-B4, first integration: alpine:3.20 runs from its pack over the JS 9P server in all three browsers, both from the proof initramfs (chroot) and as a container through wmd's Go init and crun, as the engine boots it: ls -R / finishes, apk info lists its 14 packages, and the init writes bundle/exitcode back over 9P. See P-B4.

P-B11, the wmn agent in the browser's guest (core): the container kernel and Go init boot with the engine's own command line (wmd.control=vport); the page drives the init over the control channel with a JS yamux client (ROADMAP 2.2b); python:3.12-slim and node:22-slim run from one pack per layer, with the agent as PID 1 (/.wmn/agent init) and /.wmn/agent serve exec'd beside it; hello_reply and the 13 P-K2 checks pass in Chromium, Firefox and WebKit, with failures on a loaded host (Firefox, two runs). See P-B11.

P-B10 passes in all three: the guest has a network. Its virtio-net frames go to a TCP/IP stack in a network Worker, which carries its TCP connections and DNS over one WebSocket to a small relay (relay/relay.ts). apk add curl in alpine:3.20 and pip install requests in python:3.12-slim complete, and curl and requests then fetch over HTTPS. The relay refuses private, loopback, link-local, metadata, reserved and its own host's addresses after its own DNS resolution, needs a per-session token, and caps connections and bandwidth. See P-B10.

P-B7, packs from a static host and the registry proxy: packs are content addressed (/blobs/sha256/<hex>, immutable) with one small index per image@platform; a service worker caches them after a hash check, so a second visit boots with the server gone; a Bun proxy (registry/) pulls any public Docker Hub image (anonymous token, index -> linux/arm64, sha256 checked while streaming) and converts its layers with wmd's own union.PackLayer. See P-B7.

The interface this host implements is in docs/host-interface.md.

Layout

| File | What it does | |---|---| | src/boot.js | Page API bootWmd({vmUrl, files, args, env, ...}). Downloads the files, compiles vm.wasm, creates the shared memory, the control block and the Worker pool, then runs _start in a Worker. Reports stdout/stderr and timings. | | src/worker.js | One Worker = one wasm thread. Role main runs _start (tid 1). Role pool parks on a slot and, for each spawn, instantiates vm.wasm again (same memory, a new 155-entry table) and calls wasi_thread_start(tid, arg). | | src/host/wasi.js | The 18 wasi_snapshot_preview1 imports, modelled on runner/src/wasi.rs. | | src/host/fs.js | A read-only, in-memory preopen (/g) over SharedArrayBuffers. Its fd table is process-wide: it lives in the control block, so any thread can read a file another thread opened. | | src/host/threads.js | thread-spawn: claims an idle pool slot with a compare-exchange, writes tid and arg, notifies, and returns the tid synchronously. | | src/host/clock.js | One clock for all Workers: timeOrigin + now() minus a shared start time, with a shared high-water mark so CLOCK_MONOTONIC never goes back across Workers. CLOCK_REALTIME is the shared start time plus CLOCK_MONOTONIC, so it never goes back either. | | src/host/wmd-imports.js | The wmd.* imports. ram_map→1, blk_rw→-38 (no swap disk), win_init→1, other win_*→0, snapshot→-1, chan_open→-111 without channel services; without a JIT door jit_*→-1 or no-op. createWmdImports({jit, chan, counters}): the worker passes the JIT door as jit and, when the boot has channel services, the P-B3 channel imports as chan. Calls to the stubs are counted in the control block (counters), not posted. | | src/host/chan.js | P-B3: the chan block (one SharedArrayBuffer: doorbell, port registry, a duplex ring pair per channel, the console pipe to the page, counters and the 9P round-trip histogram), the ring primitives, markServiceDead / markGuestGone, and the five guest imports (createChanImports). | | src/host/chan-services.js | P-B3: ChanHost, the host side of every channel, synchronous (step()): the 9P server per connection over one Overlay per port, the console (to the page's pipe, and page input), and the control and net stubs (P-B10: the net channel goes to the network Worker when a relay is configured). | | src/host/chan-service.js | P-B3: the service Worker around ChanHost. Waits on the doorbell with Atomics.waitAsync (spinning 200 µs after work first), holds a Web Lock for its life so the page learns when it ends, answers stats/collect, and has test hooks (die, crash, stall). | | src/host/jit-hub.js | The JIT module hub: one Worker that forwards each compiled WebAssembly.Module to every vCPU Worker, so a module is compiled once per process (finding F1). | | src/host/jit.js | P-B2, the JIT door: jit_load, jit_compile, jit_sync, jit_flush, jit_known, jit_note. One global slot list in SharedArrayBuffers, per-thread tables, counters and per-module timings. Without a module cache jit_known is -1 and jit_note a no-op; with one (W77) they read and feed jit-cache.js. | | src/host/jit-cache.js | W77, the JIT module cache across visits: bootWmd({jit: {cache}}) with a {get, put} byte store keyed by the vm.wasm's sha256. The page loads one index (block key → module, function, span) into a SharedArrayBuffer table at boot; jit_known finds a key there, gets the module's bytes from the page once (hash and length checked), and returns the function's slot; jit_note names the modules to put. See D-486. | | src/host/stdio.js | stdout/stderr rings in a SharedArrayBuffer. Workers write into them under a spin lock, and the page drains them every 16 ms. | | src/host/layout.js | Cell layout of the shared control block: tid counter, exit flag and code, pool slots, fd table, clock base, stub host-call counters. | | src/host/monitor.js | Page-side monitors: the kernel-panic scanner, the progress watchdog and the throttle meter. | | src/calib-worker.js | The throttle detector's Worker: times a fixed integer loop and a 10 ms Atomics.wait every 2 s. | | page/ | A small test page. ?case=arm64\|amd64\|hello\|wfi\|prog\|loop\|flush\|smpflush\|smc&mode=interp\|jit&auto=1 boots on load (other options: arg=, env=, prog=, jitc=, brk=, share=, door=0, watchdog=; see the head of page/main.js). State is on window.wmd, and the JIT counters are in a table on the page. page/smc-initrd.js builds the self-modifying-code initramfs. page/jit-stress.html is the multi-Worker JIT door stress test, and page/wasm-mini.js encodes its block-shaped test modules. page/idb-bytes.js is the IndexedDB byte store behind ?jitcache=1 (=fresh empties it first), on this page and the wmn test page. | | src/fs/ | P-B4, the 9P2000.L file server (no browser wiring yet): the pack reader (pack.js), the overlay of pack lowers under an in-memory upper (overlay.js), the codec and server (9p/codec.js, 9p/server.js) and Linux errno values (errno.js). See docs/9p.md. | | page/chan.html, page/chan.js, page/b3-initrd.js | P-B3/P-B4 test page: ?case=p9lat\|console\|alpine\|container\|p9load&auto=1 (options at the head of chan.js). b3-initrd.js appends a newc archive to the proof initramfs with /p9stat (a hand-assembled arm64 ELF that times N fstat calls) and the case's /proof.sh. | | test/serve.ts | A Bun static server: COOP same-origin, COEP require-corp, CORP, and a CSP with 'wasm-unsafe-eval'. It serves read-only from fixed roots. | | test/unit/ | bun test for the WASI layer, the spawn pool, the JIT door (jit.test.js, jit-share.test.js, b2-*.test.js, which include real Workers flushing under load), the stdio rings, the P-B1 monitors, counters, clocks and gate (monitor.test.js, gate.test.js) and the 9P server (9p-*.test.js; fixtures made on first run by test/fixtures-gen, see docs/9p.md). | | test/e2e/chan.spec.js, test/unit/chan*.js, test/unit/clock-strict.test.js | P-B3/P-B4: browser boots per browser (9P round trip, console and control throughput, alpine, the container init, the adversarial cases) and the ring/import/service unit tests with real Workers as guest threads. | | src/host/yamux.js, src/host/ctl.js | P-B11 (ROADMAP 2.2b): the hashicorp/yamux client (run by the channel service, ChanHost ControlSession, so pings are answered off the page) and wmd's control protocol on the page (ControlClient: run, startExec, attach, kill, procs; frames; the engine's OCI bundle, containerBundle). vm.control when a service is {kind: 'control', yamux: true}. | | page/wmn.html, page/wmn.js, page/k2.js | P-B11: an image's packs + the agent, the engine's command line, run + exec over the control session, the P-K2 checks (ported from the wmn repo's spikes/P-K2/k2.ts). ?image=python\|node&auto=1 (options at the head of wmn.js). | | src/net/ | P-B10: the guest's network. stack.js (ARP, DHCP, DNS, ICMP, TCP for the guest's frames), packet.js (framing and checksums), dhcp-dns.js, relay-client.js (the relay WebSocket), proto.js (the relay protocol, shared with the relay), net-worker.js (the network Worker). | | relay/ | P-B10: the relay server (relay.ts, Bun), its address policy (policy.ts) and session tokens (token.ts). | | page/net.html, page/net.js | P-B10: an image with a network (?image=alpine\|python&auto=1); window.net.sh(cmd) runs a command in the container. | | test/e2e/ | Playwright: the boots (boot.spec.js), the P-B1 adversarial checks (adversarial.spec.js), the tests of the P-B1 adversarial fixes (fixes.spec.js, throttle.spec.js) and the P-B1 review suites (review-*.spec.js), the wasm feature probes (features.spec.js), the P-B2 JIT boots (jit.spec.js), the JIT door stress in each browser (jit-stress.spec.js) an opt-in benchmark of host variants (jit-bench.spec.js, WMD_WEB_BENCH=1) and the JIT module cache across two visits (w77-jit-cache.spec.js; its python test needs WMD_WEB_IMAGE_FIXTURES). |

Design notes

  • Workers only. memory.atomic.wait32 traps on the main thread, so the page thread never runs wasm.
  • Thread spawn is synchronous. A Worker blocked inside wasm cannot wait for a new Worker to load. The page therefore starts 4 pool Workers up front, each holding the compiled Module, and keeps 2 spare: after each spawn the spawner posts need and the page tops up the pool asynchronously.
    • If no Worker is idle, the spawner asks for more and waits up to 10 s on the pool generation cell.
    • If none appears in that time, it returns -11 (EAGAIN) and the page prints thread pool exhausted or thread-spawn found no idle Worker.
    • Up to 128 slots are allowed.
    • A P-B1 boot spawns 2 threads (tids 2 and 3), one of which is the timer thread. Which code spawns the other was not traced.
  • Exit. proc_exit sets the shared exit flag (the first code wins), tells the page, and throws to unwind its own Worker. The page then terminates every Worker. A trap on any thread ends the machine with code 4, as in the runner.
  • Memory. One WebAssembly.Memory({initial: 6144, maximum, shared: true}). maximum is 6144 + RAM pages + 1 GiB of slack (10,240 + 16,384 pages for --ram 256). Views over it are rebuilt whenever memory.buffer changes after a grow.
  • Output. stdout and stderr go into a ring per fd in a SharedArrayBuffer (src/host/stdio.js), 32 MiB each for a boot (bootWmd({stdioRingBytes}), a power of two). The page drains the rings every 16 ms and decodes them with a streaming TextDecoder. P-B1 posted one message per fd_write; P-B2 replaced that, because a guest that writes megabytes a second flooded the page's event loop (see P-B2 problem 3). If the page falls a whole ring behind, it skips ahead, counts the skipped bytes (timings.outputDropped, timings.outputDropMarkers) and puts a line [wmd-web: N bytes of stdout were dropped] into the delivered text where the bytes are missing. The boot gate fails on that line (finding F4 below).
  • Endings. vm.exited resolves {code, reason, timings}. Guest exit codes are

    = 0. The host's own endings are negative: -1 stopped (vm.stop()) or aborted (the AbortSignal), -2 no progress: ... (the watchdog), -3 kernel panic: <line> (the guest's own exit code is then in timings.guestExitCode). An AbortSignal that fires before the Workers start makes bootWmd reject with an AbortError instead; the signal is checked after every await, so this is immediate even while vm.wasm compiles.

  • Kernel panic. The host scans the console for Kernel panic - not syncing. The arm64 kernel has CONFIG_PANIC_TIMEOUT=-1, so it reboots at once and the guest reports exit 0 (PSCI SYSTEM_RESET); the host reports -3. A kernel that hangs after a panic instead is stopped 5 s after the panic line.
  • Progress watchdog. After the main Worker starts _start, the boot ends with -2 and the line guest made no progress when nothing is written to stdout or stderr and no thread is spawned for watchdogMs. Default 60 s for an arm64 --machine, 300 s for x86 (the amd64 decompressor is silent for about 75 s in Chromium), off for user-mode programs. The limit is multiplied by the throttle ratio while the host reports throttling, and a page whose own thread was stalled restarts the count.
  • Throttle detector. A small Worker (calib-worker.js) times a fixed integer loop every 2 s (about 1% of one core). The page compares the median of the last three runs with the fastest run seen (this boot, or the throttle.baselineMs the caller passes; the test page calibrates once at load and keeps the best in localStorage). At 2x or more it emits {t:'throttle', throttled:true, ratio, ...}, writes [wmd-web] the guest is running about Nx slower ... to stderr, and vm.stats().throttle / timings.throttle carry {ratio, maxRatio, throttledSamples}. See "Background throttling" below for what it catches and why.
  • Stub host calls. Calls to the stubbed snapshot and chan_open, and to the stubbed jit_* when a boot runs without the JIT door (bootWmd({jit: false}), test page ?door=0), are counted in shared cells of the control block. vm.stats().hostCalls reads them on demand; the page gets one {t:'host-calls', counts} event at most every 250 ms, and only when a count changed. The test page keeps the latest counts, not a list, and caps its diagnostic event list at 1000 entries.
  • Releasing a finished boot. When a boot ends (exit, stop, abort, trap, watchdog, panic) the host terminates every Worker (the calibration Worker too), clears their handlers, its timers and the abort listener, terminates the JIT hub Worker, and drops the compiled module and the file buffers. Only vm.memory stays, for post-mortem reads, until vm.dispose(); the test page calls dispose() and keeps no handle. stdout is line-buffered by the guest's libc. The test page keeps about 8 MB of each stream in window.wmd (the start and the most recent output, with a [wmd-web: N bytes of stdout were dropped] line between, so a gate run on it fails too) and shows the last 128 KB. P-B1's merged branch had the same rings at 4 MiB (cherry-picked as 17e9ce50); the merge keeps P-B2's 32 MiB rings and drop markers.

Building images (wmd-web/build)

createBuilder runs BuildKit (moby/buildkit v0.33.0) in a privileged builder machine in the page and builds a Dockerfile there. The result is {index, packs}, the shape pullImage returns, so bootWmd can run it at once. Chromium, Firefox and WebKit all pass the same 18 build steps (plan docs/plans/web-buildkit-impl.md, section 10).

import { createBuilder } from 'wmd-web/build'

const builder = await createBuilder({
  vmUrl, files,                       // as bootWmd: the engine and the builder arch's guest files
  arch: 'arm64',                      // or 'amd64'
  image: { tar: builderTarUrl, sha256 }, // the builder image; must match BUILDER_PINS in src/build/pin.js
  relay: { url, protocol: 'wisp' },   // the guest's network: base images and RUN downloads
  disk: { namespace: 'my-app' },      // BuildKit's cache on an ext4 disk in OPFS (default 10240 MiB)
  converter,                          // wmdpack.wasm
  pull: ref => pullImage(ref, { fetch, converter, platform: 'linux/arm64' }), // base images for the fast path (off without it)
})
const r = await builder.build({ context: { Dockerfile: 'FROM alpine:3.20\nRUN echo hi > /x\n' } })
// r: {index, packs, log, path: 'fast'|'buildkit', ms, cached}
await builder.close()
  • The cache stays. The disk outlives the builder and the visit. After a reload, or after a tab was killed mid-build, the next build mounts the disk again and its steps come back CACHED. close({keep: false}) deletes the disk.
  • A second tab that asks for a disk already open gets a temporary disk, deleted when it closes.
  • Features: RUN --mount=type=cache and type=secret, # syntax= frontends, build args and target stages, .dockerignore, file capabilities (setcap), push to a registry (TLS, or plain http in httpRegistries), and tar: true for a Docker tar that docker load takes. --mount=type=ssh and another platform are refused before any work.
  • The fast path. With pull given, a Dockerfile with no RUN is built in the page without BuildKit, layer for layer the same as BuildKit's (40 compared cases): under 0.4 s once its base image is pulled, where a warm BuildKit takes 5 to 14 s.
  • Errors: BuildError {step, tail, reason}: reason is 'no-space', 'quota', 'disk', 'timeout', 'pin' or 'secret'. Cancelling with signal stops the step and leaves nothing behind.
  • The builder images are built with make web-builder (wmd builds them; Docker Desktop is not used) into web/dist/builder/builder-<arch>.tar, and their sha256 is pinned in src/build/pin.js. make web-release puts them next to the release tgz.
  • Docker Hub's rate limit applies to base images. registryMirrors gives BuildKit a mirror to try first.
  • Tests: bash ../scripts/bootlock.sh run --max 1800 -- bunx playwright test -c test/build --project=<browser> (build.spec.js: the 18 steps; parity.spec.js: the fast path against BuildKit). test/build/seed-mirror.sh seeds a local mirror so the runs never touch Docker Hub.

Running the tests

cd web
bun install                       # @playwright/test 1.63.0 (browsers already in ~/Library/Caches/ms-playwright)
bun test test/unit                # WASI layer, spawn pool, JIT door, stdio rings, monitors, counters, clocks, gate, 9P; no browser

bunx playwright test --project=chromium                   # boots + adversarial + features
WMD_WEB_AMD64=1 bunx playwright test boot --project=chromium -g amd64   # ~6.6 min
bunx playwright test boot --project=firefox
bunx playwright test boot --project=webkit
bunx playwright test features                             # all three browsers, no guest boot
bunx playwright test jit-stress                           # JIT door stress, all three browsers, no guest boot

# P-B2 (each under the lock, one browser per run, about 3 min each):
bunx playwright test jit.spec boot.spec jit-stress --project=chromium
bunx playwright test jit.spec boot.spec jit-stress --project=firefox
bunx playwright test jit.spec boot.spec jit-stress --project=webkit
WMD_WEB_BENCH=1 bunx playwright test jit-bench --project=chromium   # host variants, one boot each

# P-B2 adversarial fixes (each under the lock, per browser): F1 multi-vCPU --jit-batch 1
# boots and F4 drop-marker boots; B2F_SHARE=0 reproduces F1 in Firefox
bunx playwright test b2-fixes --project=firefox
WMD_WEB_B2ADV=1 bunx playwright test b2-adversarial --project=firefox   # the review's own suite

# P-B3/P-B4 adversarial fixes (each under the lock, per browser; WMD_WEB_B3ADV=1 opts in):
WMD_WEB_B3ADV=1 bunx playwright test b3-fixes --project=webkit -g "default|record|F2/F3"   # ~3 min
WMD_WEB_B3ADV=1 bunx playwright test b3-fixes --project=webkit -g "measure cache"          # F1 numbers, ~8-12 min
WMD_WEB_B3ADV=1 bunx playwright test b3-adversarial --project=webkit -g "rings"            # the review's own suite, in parts

# P-B1 adversarial fixes and the P-B1 review's suites (each under the lock, per browser):
bunx playwright test fixes --project=firefox              # (under the lock) the adversarial-fix tests, any browser
REVIEW_SEQ_BOOTS=5 bunx playwright test review-lifecycle -g sequential --project=webkit   # (under the lock) memory over 5 boots

# Real background throttling: opens a headed Chromium window (under the lock).
REVIEW_HEADED=minimized bunx playwright test throttle --project=chromium   # must report throttling
REVIEW_HEADED=visible   bunx playwright test throttle --project=chromium   # must not; full strict gate
REVIEW_HEADED=minimized bunx playwright test review-lifecycle -g headed --project=chromium   # the adversary's version

Environment variables:

  • WMD_REPO: the wmd checkout the boot files are served from. Default ~/keg-hub/external/apps/wmd. The files used are guest/linux/dist/{arm64,amd64}/* and guest/vm/build/progs/arm64/hello.
  • WMD_VM_WASM: the vm.wasm to serve. Default ~/.wmd/bin/guest/arm64/vm.wasm.
  • PORT: the server port. Default 8791 for Playwright, 8787 for bun test/serve.ts. The P-B2 runs used PORT=8793, so they would not collide with another worktree's server.
  • WMD_WEB_BOOT_TIMEOUT_MS: the per-boot bound. Default 420000 (300000 in chan.spec.js).
  • WMD_GUEST_DIR: where /files/guest/arm64/ (the container kernel and the Go init's initramfs, P-B4) is served from. Default ~/.wmd/bin/guest/arm64, read only.
  • WMD_WEB_9P_FIXTURES: the packs served at /fixtures/9p/ (default test/fixtures/9p, made by the unit tests' ensureFixtures()).
  • chan.spec.js: WMD_WEB_CONSOLE_BYTES (default 50 MiB), WMD_WEB_UART=BYTES (runs the UART comparison), WMD_WEB_HVC0_TIMEOUT_MS (120000), WMD_WEB_RUNCLOG=1 (crun's debug log into bundle/runc.log), WMD_WEB_P9LAT_QUERY (extra page options, e.g. &spin=0). The P-B3/P-B4 runs used PORT=8796.

The logs and timings of each boot are written to test-results/<browser>-<case>.{stdout.log,stderr.log,timings.json}.

Numbers (2026-10-04, Apple Silicon, one run each, interpreter, 1 vCPU, --ram 256)

All times are in ms. Browser times start when bootWmd is called on the page, so they include the download. Native times start at process start. The canary read 617–649 ms around these runs, so the numbers are reliable.

| arm64 proof boot | native wmd-run | Chromium 153 (headless shell) | Firefox 155 | WebKit 26.6 | |---|---|---|---|---| | download (vm.wasm + Image + initramfs, 43.2 MB, localhost) | n/a | 62 | 165 | 61 | | WebAssembly.compile(vm.wasm) | n/a (Cranelift, cached) | 9 | 25 | 11 | | main Worker instantiated | n/a | 107 | 230 | 89 | | first console byte | 222 (Linux version) | 345 | 654 | 334 | | wmd-init: up | 19,977 | 17,342 | 21,653 | 19,169 | | PROOF-DONE | 51,554 | 44,042 | 57,534 | 49,166 | | reboot: Power down | 51,771 | 44,218 | 57,752 | 49,374 | | total (exit) | 52,024 (wall 51.7 s) | 44,240 | 57,772 | 49,431 |

  • Native reference. It was run once: wmd-run --wmd-stats --dir guest/linux/dist guest/vm/build/vm.wasm -- --machine --arch a64 --board arm64 --mode interp --cpus 1 --ram 256 --kernel .../arm64/Image --initrd .../arm64/initramfs.cpio.gz --append "console=ttyAMA0 earlycon=pl011,0x9000000 rdinit=/init loglevel=8 wmd.proof wmd.exit" --stats.
  • Compile time. The compile times are what WebAssembly.compile returns. That is the engines' baseline tier (Liftoff, or equivalent); optimizing tier-up happens later, in the background.
  • Browsers vs native. In Chromium and WebKit the interpreter boot is faster than native wasmtime, by 15% and 5%. Firefox is 11% slower. This is one run each, and the cause was not investigated.

| amd64 proof boot (Chromium) | ms | |---|---| | download (vm.wasm + bzImage + initramfs, 11.8 MB) | 37 | | compile | 7 | | first console byte (decompressor) | 403 | | Linux version | 74,841 | | wmd-init: up | 238,001 | | total (exit 0, all boot-gate patterns met) | 392,857 |

  • amd64 marks. The PROOF-DONE and power-down marks were not captured on this run: the x86 console sends CRLF and the mark regexes were anchored. The boot gate itself strips CR and passed. Mark matching now strips CR (commit 6e4735e5).
  • No native amd64 reference. The brief allowed one native run, and it was spent on arm64.

After the adversarial fixes (same day, every boot under the lock, canary 600-650 ms): PROOF-DONE at 40.4-42.5 s in Chromium, 52.5-56.4 s in Firefox and 45.9-47.3 s in WebKit, TIMER-DELTA 1.1 everywhere. The review measured 40.3-45.8 s (Chromium), 55.6-58.6 s (Firefox) and 48.0-50.5 s (WebKit) before the fixes, so the throttle detector's Worker and the 250 ms tick cost no measurable time.

Browser wasm feature support (Playwright 1.63 browsers, test/e2e/features.spec.js)

Each feature was checked by compiling, instantiating and running a tiny module in a cross-origin isolated page.

| Feature | Chromium 153 | Firefox 155 | WebKit 26.6 | |---|---|---|---| | cross-origin isolated, SharedArrayBuffer | yes | yes | yes | | shared memory 384 MiB initial / 1 GiB max | yes | yes | yes | | threads (shared memory + atomics) | yes | yes | yes | | SIMD128 | yes | yes | yes | | bulk memory | yes | yes | yes | | multi-memory | yes | yes | yes | | two shared imported memories + atomic load from memory 1 (the JIT block-module shape) | yes | yes | yes | | tail calls (return_call) | yes | yes | yes | | memory64 | yes | yes | yes |

These are Playwright's engine builds. Shipping Safari and Firefox releases may differ. P-B5 has to re-check them on real browsers.

Firefox and WebKit

Both pass steps 1–3, the same tests as Chromium. Each prints hello from aarch64 and board init ok, and the arm64 proof boot passes the full gate with exit 0. The timings are in the table above. Nothing needed per-browser code.

P-B3: channels on shared-memory rings

wmd.chan_* as runner/src/chan.rs has them, for Workers (src/host/chan.js):

  • One chan block per machine. A SharedArrayBuffer holds a header (doorbell, service state, guest-gone flag, the ports the services listen on), 8 channel slots, and per slot two byte rings: guest→host and host→guest, 1 MiB each by default (bootWmd({channels: {ringBytes}}), any power of two from 64 bytes), plus an 8 MiB pipe for the console's bytes to the page: about 25 MiB of SharedArrayBuffer per boot, touched only as it is used. Positions are 32-bit counters that wrap. Boots without channels get the old stubs and no service Worker.
  • Guest side: blocking imports. chan_send copies straight from guest memory into the ring, waiting (Atomics.wait on the ring's "space" sequence cell) while it is full; it holds the ring's producer lock for the whole message, so two vCPUs' 9P requests never interleave, whatever their size. chan_recv returns what is there (a partial read is normal), waits up to the timeout (rounded up to ms, as chan.rs) on the ring's "data" sequence cell, returns 0 on timeout and -1 at EOF. Every wait is at most 1 s at a time, and re-checks the machine's exit flag, the service state and the channel's own state, so nothing in the guest can hang on a peer that is gone.
  • Return values as chan.rs. chan_open: -22 for a port outside 1..65535, -111 when no service listens on the port (or the service is dead), else the lowest free handle. The port decides the service; kind is only recorded. -14 bad range, -9 bad handle, -32 when the peer is gone or after our own chan_shutdown. chan_shutdown is a half-close: the host reads to the end, sees EOF, and closes its side once its replies are out. chan_close wakes any thread blocked on the handle (-1 / -32) and frees the slot only after the service has dropped the session, so a reply in flight can never land in a new channel that reused the handle.
  • Host side: a service Worker (src/host/chan-service.js), never the page. It never blocks: it waits on the doorbell cell with Atomics.waitAsync and spins on it for 200 µs after any work (channels.spinUs), so its event loop stays free for the page's messages. Services by the kind registered for the port: 9p (src/fs/9p/server.js, a NinePServer per connection over one Overlay per port: packs as lowers, an in-memory upper), console (to the page through a pipe ring the page drains every 16 ms; no loss: when the page falls behind, the service stops reading and the guest blocks; page input through vm.consoleWrite, which takes at most 4 MiB the guest has not read and says how much it took), control (a stub: reads, counts and CRC-32s), net (a stub: counts the 4-byte-length frames and drops them; P-B10 adds the relay).
  • Backpressure. The 9P service answers at most OUT_LIMIT (4 MiB) of replies the guest has not read; after that it stops reading requests, so tiny requests cannot make it queue unbounded replies, and the guest's sends block on a full ring.
  • A dead service. The service holds a Web Lock for its whole life; the page asks for the same lock and is granted it when the Worker ends, however it ends (terminated, closed, crashed, killed by the browser). The page then calls markServiceDead: every guest waiter wakes, sends fail (-32), receives drain what is left and then get -1, chan_open refuses. An exception in the service's loop marks it dead from inside too. The guest's 9P pump powers the machine off on -1 (chan_pump.c host_gone) with exit 0; bootWmd reports that as -4 channel service died: ... (the guest's code in timings.guestExitCode), as a panic is -3.
  • The guest ends. finish() sets the guest-gone flag, which also wakes every guest thread still blocked on a channel (-32 / -1 at once); the service drops every session (counting any message it held half-read in sessionsDropped, never handing it to the server) and stops; then the page terminates it with the other Workers.
  • Counters. vm.chanStats() / timings.chan: per channel bytes each way and how often and how long a guest send waited for room (sendWaits, sendWaitMs: the "host fell behind" signal); the 9P round trip measured by the guest's threads (send of a T-message to its R-message delivered by the pump: a histogram, p50/p90/p99) and the service's per-request time; per-kind bytes, frames, EOFs, protocol errors.
  • Files out. channels.collect: [{port, path}] reads files out of the 9P trees when the machine ends (timings.chanFiles), e.g. bundle/exitcode.

Which console the guest uses

The proof boot's console is the PL011 UART, which vm.wasm writes to its stdout (the stdout ring), and so is the container kernel's (console=ttyAMA0, internal/engine/run.go). Containers' stdio does not go through either: the Go init runs its yamux session, which carries the container's stdin/stdout/stderr, on virtio-console port 1 (wmd.control, /dev/vportNp1), i.e. the control channel (kind 3). Nothing in wmd uses port 0 (hvc0, kind 4). The throughput test therefore writes 50 MiB into each of: the control channel (the path container output will take), hvc0 (the console channel), the UART (the stdout ring) and /dev/null (what the guest's yes | head costs by itself).

Numbers (2026-10-05, Apple Silicon, Playwright 1.63, --mode jit, 1 vCPU, --ram 256)

One run each per browser (test/e2e/chan.spec.js, the final runs; canary 605–634 ms around the Chromium and Firefox runs; the WebKit run started contended, canary 1,378 ms before and 604 after, which shows in its first p9lat run). Native: ~/.wmd/bin/wmd-run with wmd's Go 9P server (guest/linux/testhelper) over loopback TCP, the same initramfs, canary 657–680 ms.

9P round trip. /p9stat FILE 2000 on the 9P mount (no cache: every fstat is one Tgetattr, which the service's counts confirm: 6,050+ Tgetattr), three runs after a warm-up of 50, µs per call; on tmpfs for the syscall's own cost:

| | Chromium | Firefox | WebKit | native, Go server over TCP | |---|---|---|---|---| | fstat on the 9P mount (3 runs) | 137.4, 109.5, 101.4 | 153.1, 141.0, 138.4 | 187.2, 138.9, 88.9 | 113.2, 104.1, 108.0 (2nd boot; 1st: 207, 127, 133) | | fstat on tmpfs | 14.9, 13.8, 14.1 | 19.3, 19.0, 18.6 | 21.0, 19.7, 17.5 | 7.5, 6.9, 7.3 | | channel round trip, guest threads (send of the T-message → R-message in the pump), p50 / p90 / p99 | 30.5 / 40.5 / 100.5 | 20.5 / 40.5 / 139.5 | 20.5 / 100.5 / 559.5 | – | | service time per request, p50 / p99 | 0.5 / 25.5 | 0.5 / 59.5 | 0.5 / 99.5 | – |

  • P-B3's bar (under 500 µs) is met by a wide margin in all three, and the browser is as fast as native wmd over TCP: almost all of the ~100 µs is the guest (syscall, v9fs, virtio, the MMIO kick, the interrupt and the wake-up of the waiting process in the emulated kernel), not the channel (20–40 µs) or the server (under 1 µs typical).
  • The histograms are quantised by each browser's coarsened clock (performance.now(): 5 µs in Chromium, 20 µs in Firefox and WebKit), hence the x0.5 values.
  • The spin is worth about 20 µs. Chromium, two boots each: with spin=0 the channel's p50 was 40.5 µs and fstat 135–205 µs; with the default 200 µs spin, 19.5–20.5 µs and 104–157 µs.

Console throughput. yes | head -c 52428800 (50 MiB; since W53 piped through cat, see "hvc0 after W17"), timed by the guest (/proc/uptime) and by the service (first to last byte read):

| | Chromium | Firefox | WebKit | |---|---|---|---| | into the control channel (vport, container stdio path) | 14.05 s, 3.7 MB/s (service 13.95 s) | 15.86 s, 3.3 MB/s | 10.69 s, 4.9 MB/s | | times the guest waited for the host | 0 | 0 | 0 | | into /dev/null (the guest alone) | 11.83 s, 4.4 MB/s | 13.81 s, 3.8 MB/s | 12.33 s, 4.3 MB/s | | through the UART (stdout ring) | 368.4 s, 0.14 MB/s, 0 bytes dropped (WMD_WEB_UART, Chromium only) | – | – | | into hvc0 (console channel) | fails: the guest's IRQ storm, see problem 1 | fails | fails |

The control channel keeps up: every byte arrived, in order, and no chan_send ever found the ring full. In Chromium and Firefox the run took 15–19% longer than into /dev/null, which is the guest's own virtio-console write path (copying into 32 KiB buffers, the MMIO kick, the interrupt); in WebKit the control run was faster than its /dev/null run, so one run each is noisy at that level. The UART is 26x slower: one MMIO trap per byte, and the tty turns each \n into \r\n.

Tests

bun test test/unit                       # 241 tests; chan.test.js: rings, imports, ChanHost, the service Worker;
                                         # b3-fixes.test.js: the fixes of the P-B3/P-B4 review
# each under the boot lock, one browser at a time (about 4 min each):
PORT=8796 bunx playwright test chan.spec --project=chromium
WMD_WEB_UART=52428800 PORT=8796 bunx playwright test chan.spec -g UART --project=chromium   # 6 min

Unit (test/unit/chan.test.js, Workers play the guest's threads and the real service Worker serves): a 64-byte ring carrying 1 MB of odd-sized chunks; full and empty; 32-bit counter wrap; open/handle/range errors; timeouts (0 polls, 20 ms waits ≥ 19 ms, 1 ns rounds up to 1 ms); partial reads; shutdown both ways; close freeing the slot only after the host drops it; a dead service; 9P frames through a 64-byte ring giving the same replies as the server itself; a bad frame size closing the channel; 4 producers x 200 messages up to 3.5x the ring never interleaving; 512 KiB Twrite/Tread over 4 KiB rings; rings full on both sides (the sender blocks after the host's 4 MiB of unread replies, nothing is lost, everything arrives in order once the guest reads); small requests for big replies (the host answers no more than OUT_LIMIT + a ring ahead); the service terminated while a sender is blocked mid-message (-32) and a receiver blocked forever (-1), within 500 ms; an exception in the service (-1, service dead); the guest gone with half a frame sent (dropped, never given to the server); a sender blocked on a full ring returning when the machine exits; console input in order through a 64-byte ring; net and control stubs.

Browser (test/e2e/chan.spec.js), each in Chromium, Firefox and WebKit:

| | Chromium | Firefox | WebKit | |---|---|---|---| | 9P round trip (P-B3 bar) | pass | pass | pass | | control channel 50 MiB, every byte, no waits | pass | pass | pass | | hvc0 50 MiB | fail (guest, problem 1) | fail | fail | | /dev/null baseline | pass | pass | pass | | alpine from packs (proof initramfs) | pass | pass | pass | | alpine as a container (Go init, crun) | pass | pass | pass | | service terminated by the page mid-traffic → -4, ended 262 / 267 / 258 ms after | pass | pass | pass | | service self.close() mid-traffic (only the lock tells) → -4, 261 / 259 / 257 ms | pass | pass | pass | | service throws mid-traffic → -4, 257 / 259 / 257 ms | pass | pass | pass | | guest powers off with 9P reads in flight → exit 0, no service errors | pass | pass | pass | | 4 KiB rings: alpine with 256 KiB messages | pass | pass | pass |

The P-B1 and P-B2 suites still pass after the clock change (problem 2): boot.spec and jit.spec in Chromium (14 passed, 1 skipped), and the P-B1 and P-B2 proof boots in Firefox and WebKit.

Problems found, and their causes

  1. hvc0 (the console channel) crawls and the kernel disables its interrupt. With yes | head -c N > /dev/hvc0 the guest prints irq 14: nobody cared after a few seconds and Disabling IRQ #14, and goes on at about 60 KB/s. The cause is in the guest, not the host: native wmd-run --console PORT (a nc -l behind the port) does exactly the same (4 MiB in 70 s, the same two lines). Linux's hvc path (put_chars → __send_to_port) kicks the queue and then polls for the used buffer with interrupts off, while vm.wasm's virtio-console (guest/vm/src/dev/console.c console_notify) raises an interrupt for every transmit buffer. By the time interrupts are back on the buffer has been taken, vring_interrupt finds nothing and returns IRQ_NONE, and after 100,000 of those Linux disables the line. A raw tty (stty raw -echo -opost) does not help. A guest fix would be to honour VRING_AVAIL_F_NO_INTERRUPT on the console's transmit queues (virtio_used_irq_wanted, as p9proxy.c does since D-324) or not to interrupt for transmit completions the driver polls; not built here. Nothing in wmd uses port 0, and the container stdio path (port 1) is not affected: its driver path takes the interrupts. Update (wmd main, W17 2ec1cf9d): vm.wasm now raises no interrupt for port 0's transmits; the storm and the disabled IRQ are gone. hvc0 is still about 33-72 KB/s in the browsers and 69 KB/s natively, so the 50 MiB test still times out: a vm.wasm/guest cost, not the host (P-B11 section "hvc0 after W17").
  2. The container's crun hung: no entropy in the browser. The first container boot stopped after wmd-init: writing runc bundle, the vCPU idle in WFI, and no random: crng init done in the log. Linux's jitter entropy (try_to_generate_entropy, run when getrandom() blocks) reads the cycle counter 8,192 times and gives up when too few reads differ. The guest's counter is CLOCK_MONOTONIC / 16 ns, and browsers coarsen performance.now() (5–20 µs), so consecutive reads were equal; natively the clock has ns resolution. Without a seed (the direct-run path gets none; the control path's run request carries one) crun blocked in getrandom() forever. Fix (src/host/clock.js): CLOCK_MONOTONIC is strictly increasing, every read at least 16 ns (one counter tick) after the previous one on any Worker; it can run ahead of real time by no more than 16 ns per read made within one coarsened step. After it, crng init done came at 22 s and the container ran (test/unit/clock-strict.test.js fails on the old clock). The entropy this gives is weak; real entropy should come from the host (crypto.getRandomValues): the control channel's seed, or a /chosen/rng-seed in vm.wasm's device tree (a guest change). Update (wmd main, W18 70d03426): vm.wasm now puts 64 host bytes in /chosen/rng-seed (WASI random_get, which src/host/wasi.js serves from crypto.getRandomValues); browser boots log crng init done at 0.000000 (P-B11 section "Review of 70d03426").
  3. 9P reply amplification. The first version read a whole chunk of requests and answered all of them before checking its output limit: 400 23-byte Treads made 26 MB of queued replies, and the "rings full on both sides" test passed or failed by timing (1 in 6 runs). The service now answers only while less than OUT_LIMIT of replies wait unread and holds the rest of the requests back.
  4. The guest's udhcpc waits 15 s in the container boot: eth0 exists and nothing answers (the net stub drops frames). That is 15.5 of the container boot's 25.7 s (Chromium). P-B10's relay, or a DHCP answer from the stub, removes it. Update (P-B10): with a net service the network Worker answers DHCP; udhcpc takes 0.4-0.5 s.
  5. busybox stty -F said "Not a tty" on /dev/hvc0; the test sets the tty up through a descriptor instead (exec 3<>/dev/hvc0; stty ... <&3).

P-B4: alpine from packs

Two paths, both from alpine.pack (alpine:3.20 linux/arm64, made by test/fixtures-gen from the layer in ~/.wmd/data, read only) served by the JS 9P server under at: 'rootfs' with bundle/config/config.json beside it, i.e. the tree the engine exports (internal/engine/ninep):

  1. Proof kernel and initramfs (?case=alpine). /proof.sh mounts the share at /mnt (p9MountOptions(): trans=virtio,version=9p2000.L,msize=262144,cache=mmap, see the review's F1 below; it was cache=loose for the numbers in this table), runs ls -R /mnt/rootfs, mounts proc, sys and devtmpfs in it and runs chroot /mnt/rootfs /sbin/apk info, a shell, ls -R / inside the chroot, and writes a file into the upper.
  2. The container path, as the engine boots it (?case=container): the container kernel and the Go init's initramfs from ~/.wmd/bin/guest/arm64, the engine's kernel command line without wmd.control (wmd.init wmd.p9tag=wmdroot wmd.exit, plus p9InitCmdline(), wmd.p9msize=262144 wmd.p9cache=mmap, since the review; the init then runs the bundle's process directly, as guest/linux/test-init-boot.sh does without control). The init mounts the 9P share at /run/wmd9p, binds rootfs/, writes runc's config from bundle/config/config.json and runs crun; the process is /bin/sh -c '...; ls -R /; apk info; ...' in alpine. The init then writes bundle/exitcode over 9P, which the page reads back (channels.collect).

| | Chromium | Firefox | WebKit | |---|---|---|---| | proof initramfs: boot to PROOF-DONE (ms) | 9,943 | 12,162 | 9,627 | | ls -R /mnt/rootfs (710 lines, 0 errors) | 0.88 s | 0.69 s | 0.84 s | | chroot ... apk info (14 packages) | 0.43 s | 0.38 s | 0.40 s | | ls -R / in the chroot (proc and sys mounted) | 3.56 s | 5.84 s | 3.42 s | | 9P requests / channel round trip p50 | 2,774 / 35.5 µs | 2,774 / 40.5 µs | 2,774 / 80.5 µs | | container: bundle mounted (ms) | 19,654 | 19,631 | 19,667 | | container: ls -R / (24,334 lines, 0 errors), apk info (14), B4-DONE (ms) | 25,400 | 27,649 | 25,406 | | container: exit code; bundle/exitcode read back | 0; 0 | 0; 0 | 0; 0 |

Of the 19.7 s before the bundle mount, 4 s are the kernel and the init's start and 15.5 s are udhcpc waiting with no network (problem 4).

What the Go init needs over the control channel (not built here)

With wmd.control=vport (the engine's command line) the init (guest/container2wasm/cmd/init) opens the virtio-console port named wmd.control (/dev/vportNp1; kind 3 here) and runs a hashicorp/yamux server on it; the host is the yamux client (keepalive every 5 s) and opens one stream per operation (internal/engine/control.go, ctlproto.go):

  • run: one JSON line {"op":"run","now":<unix s>,"seed":<base64, 32 bytes>,"p9tag":"wmdroot","p9opts":"trans=virtio,version=9p2000.L,msize=65536,cache=loose"}. The init refuses a first run without p9opts. now sets the guest clock and seed is credited to the kernel's pool (RNDADDENTROPY), which is the proper fix for problem 2 (crypto.getRandomValues on the host). Then frames both ways, id[1] | len[4] BE | payload: 0 control ({"resize":[r,c]}, {"close_stdin":true}), 1 stdin, 2 stdout, 3 stderr, 4 exit (4-byte BE code, optional flags, bit 0 "synced"). The host sends close_stdin at once when there is no stdin.
  • exec / kill / pause / resume / ps / stats: the same line with their fields; non-streaming ops answer one JSON line.
  • After the main process exits the init syncs, closes the session, writes bundle/exitcode and powers off.

So P-B4 with the engine's own command line needs a yamux client in the service Worker (kind 3), the run request above, and the frame demux to the page; the transport underneath (the control channel) is the one measured above.

What remains for P-B4

  • The control channel's yamux client and run request (above), instead of the direct-run command line.
  • Packs from OPFS (syncHandleSource is written, untested in a browser; the tests fetch the pack into memory), several layers per image, and writing the upper back out.
  • Network (P-B10): the 15 s udhcpc wait, and apk add.
  • Entropy from the host (problem 2) once the control channel carries the seed.
  • The browser engine's run request must send p9opts from p9MountOptions() (cache=mmap), not the native engine's cache=loose (review F1 below).

P-B3/P-B4 adversarial review: findings and fixes

The independent review (test/b3-adversarial-results.txt, commit d6d41e41) found F1-F5. Fixes on wmd-web-b3; each has a test that failed before and passes after (unit: the first 13 tests of the new test/unit/b3-fixes.test.js run against the pre-fix src/ give 2 pass, 11 fail; the two that pass are F4a's gap tests, which fail under the review's mutation M8 instead; the O_APPEND and step-budget tests, added later, each failed before their fix). Browser tests: test/e2e/b3-fixes.spec.js (opt-in, WMD_WEB_B3ADV=1, under the boot lock).

| | Status | Fix | Test | |---|---|---|---| | F1 full upper + cache=loose hangs the writer | fixed for the browser's mount options: the guest mounts with cache=mmap; cache=loose cannot be fixed host-side (below) | src/host/p9mount.js (p9MountOptions(), p9InitCmdline()), used by page/chan.js, page/b3-initrd.js, test/features/b3r-page.js | b3-fixes "the upper memory cap with the default mount options" (C/F/W pass); the review's cache=loose test still fails, as it must | | F1 follow-up: concurrent O_APPEND under cache=mmap lost records | fixed | src/fs/9p/server.js Twrite appends for O_APPEND fids (as QEMU) | unit "two fids opened with O_APPEND ..."; the review's container torture (conc.append) and b3-fixes "torture on the default mount options" | | F2 no default upper cap | fixed | src/boot.js upperCapDefault(), channels.maxUpperBytes, timings.chanUppers; src/fs/overlay.js full()/fullHits/onFull; chan-service.js upper-full message; chan-services.js snapshot().uppers | unit F2 (3); b3-fixes "channels.maxUpperBytes ..." and "the default cap ..." | | F3 console input unbounded | fixed | src/host/chan.js consoleInputGate, H_CIN_TAKEN; chan-services.js consoleInput() bound; boot.js vm.consoleWrite() returns the bytes taken, vm.consoleSpace() | unit F3 (3); the review's unit finding test now passes; b3-fixes 512 MiB vm.consoleWrite to a guest that reads nothing | | F4a recv vs the exit flag untested | test added | - | unit F4a (2): fail under M8 (drop \|\| exiting()), pass otherwise | | F4b chan.spec leak check vacuous | made real | - | chan.spec "powers off ... nothing leaks": every Worker the page made was terminated (Worker wrapper), Chromium: no worker target left (CDP) | | F4c control order/content unchecked | fixed | chan-services.js sink CRC-32 (kinds.control.crc32) | unit F4c; chan.spec control 50 MiB: CRC equals that of yes \| head -c 50M | | F4d "the guest never waited" not asserted | asserted | - | chan.spec control: sendWaits 0 | | F5 blocked guest threads wake only at the next 1 s slice | fixed | chan.js markGuestGone kicks every ring; recv/send also stop on the guest-gone flag | unit F5 (3): 0.1-0.3 ms after (700 ms before); the review's ring exit test bound 2,000 -> 600 ms |

F1: why cache=mmap, with numbers

Why loose cannot be fixed on the host side. Linux 6.1's v9fs with cache=loose writes into the page cache; write(2) never talks to the server. Writeback sends one 4 KiB page per Twrite (fs/9p/vfs_addr.c v9fs_vfs_writepage, v6.1.187). On any error but EAGAIN the page is dropped and the error only recorded (mapping_set_error) for a later fsync/close; EAGAIN redirties the page (an endless retry). So no errno (ENOSPC, EIO, ...) the server can return makes write(2) fail, and no headroom the server could reserve helps either: whatever it accepts past the cap is still accepted. Measured (b3-fixes "cache=loose, for the record", a 48 MiB cap): dd bs=1M count=64 conv=fsync writes all 64 MiB and fails only at its fsync (ENOSPC); a further 16 MiB dd without fsync returns 0, sync returns 0, and after drop_caches the server holds at most the 48 MiB: the rest was lost without the writer knowing. A writer past the dirty limit sits in balance_dirty_pages while writeback drains failing pages one round at a time (the review's hang). With cache=mmap and cache=none, write(2) is a Twrite of up to msize (v9fs_file_write_iter) and ENOSPC reaches it at once.

cache=mmap over cache=none: the same write path, and shared writable mmap works (none refuses it with EINVAL, wmd D-062; the review's torture has an mmap MAP_SHARED check).

What cache=mmap broke, and its fix: O_APPEND. Without the page cache, v9fs takes an O_APPEND write's offset from its i_size without a lock (v9fs_file_write_iter takes no inode lock), so processes appending to one file through their own opens overwrite each other's records: the review's torture inside a container (crun, cache=mmap from the new default) failed conc.append: 565 records, 191 out of order. QEMU's server does not have this problem (its pwrite on an O_APPEND host file appends) and neither does Linux's pwrite(2); wmd's Go server ignores O_APPEND, as this one did. The JS server now writes every Twrite of a fid opened or created with O_APPEND at the end of the file (src/fs/9p/server.js Twrite; unit test in b3-fixes.test.js, fails without it). Under cache=loose writes go through the writeback fid, opened without O_APPEND, so nothing changes there.

Cost. b3-fixes.spec.js "measure cache=X", one boot each (proof initramfs, 2 vCPUs, 512 MiB, msize 262144), then a container boot (Go init + crun) with the same mode; seconds from the guest's /proc/uptime, one run each (they vary by about ±30% run to run, e.g. WebKit mmap ls -R 1.22 and 2.63 s); canary 0.62-0.68 s around every run except where noted.

| | Chromium none / mmap / loose | Firefox none / mmap / loose | WebKit none / mmap / loose | |---|---|---|---| | ls -R /mnt/rootfs (710 lines) | 1.08 / 1.15 / 0.70 | 1.49 / 1.24 / 0.99 | 2.47 / 2.63 / 1.75 | | the same again (warm) | 0.70 / 0.68 / 0.30 | 1.68 / 1.01 / 0.33 | 1.06 / 1.39 / 0.63 | | chroot apk info | 0.57 / 0.52 / 0.40 | 0.85 / 0.48 / 0.42 | 0.68 / 0.54 / 0.53 | | 100 x exec /bin/true in the chroot | 3.18 / 3.39 / 2.99 | 5.79 / 2.92 / 2.73 | 3.67 / 3.38 / 3.41 | | ls -R / in the chroot | 4.35 / 4.27 / 3.84 | 12.27 / 6.47 / 6.13 | 6.05 / 4.78 / 5.01 | | 100 MiB write / copy / read, MB/s (sha256 equal) | 37.0/57.0/95.4 / 37.9/54.8/82.9 / 33.5/40.3/93.5 | 34.5/19.5/45.8 / 40.1/26.4/40.9 / 29.2/21.8/55.2 | 43.3/34.8/67.1 / 43.3/28.7/84.9 / 38.0/27.0/88.5 | | 100,000-file create, s (µs/file) | 78.9 (789) / 81.6 (816) / 134.5 (1345) | 158.5 (1585) / 159.2 (1592) / 191.7 (1917) | 146.3 (1463) / 102.4 (1024) / see below | | container boot: ls -R / + apk info done (ms after start) | 25,676 / 25,659 / 25,557 | 26,736 / 26,551 / 25,965 | 28,443 / 26,885 / 26,829 |

So cache=mmap costs metadata-heavy reads (no dentry/attribute cache): a cold ls -R of the rootfs +0.25-0.9 s, a warm one +0.4-0.8 s, apk info +0.0-0.1 s, and sequential reads of a big file 4-26% (no readahead); it is faster for writes (100k creates 1.2-1.65x faster than loose, writes 13-37% and copies 6-36% faster), and the container boot is the same (within 1.6 s of 25.6-28.4 s, most of it udhcpc's 15 s). cache=none and cache=mmap are within run-to-run noise of each other.

WebKit, cache=loose, 100,000 files: WebKit ended the page (Playwright saw a navigation, the page came back empty) 25-40k files into the create, 3 times of 3 at 2 vCPUs (with and without the 100 MiB copy before; the review's run at 1 vCPU and msize 65536 passed in 122.5 s), and once of two under cache=none in a run where the canary read 1.28 s afterwards (the retry passed). Not investigated further: the browser gives no reason (no crash event, no report in ~/Library/Logs/DiagnosticReports). The WebKit loose row comes from a boot without the 100k-file step (B3FIX_SKIP=bigdir).

F2: the default cap

bootWmd({channels}) caps each 9P service's upper at channels.maxUpperBytes or, by default, upperCapDefault(): an eighth of navigator.deviceMemory from 256 MiB to 1 GiB (Playwright's Chromium reports 16 GiB here: 1 GiB), 512 MiB where the browser does not say (Firefox, WebKit). A service's own maxUpperBytes wins; Infinity turns it off. timings.chanUppers says what each port got and why, vm.chanStats().service.uppers the bytes used, the cap and the refusals. The first refused write (then at most one every 10 s) puts on onStderr: [wmd-web] the guest's files on 9P port 564 are full: the upper holds 32 MiB of its 32 MiB cap (channels.maxUpperBytes); the guest's writes get ENOSPC (No space left on device), and an {t:'chan', ev:'upper-full'} event. Only file contents count (as before); names, inodes and xattrs do not.

F3: console input

vm.consoleWrite(data) returns the bytes it took (it returned true), at most vm.consoleSpace(): the page counts what it posted, the service counts in the chan block what it has put into the guest's ring (or had to drop), and the difference never exceeds channels.consoleInputBytes (4 MiB). When it takes less than it was given, onStderr says once [wmd-web] console input is full: the guest has not read the 4 MiB already queued ... (again after a write that fits), plus a console-full event. The service also bounds its own queue (anything posted past the bound by other means is dropped and counted: service.console.inputDropped). For a string the count is of its UTF-8 bytes. Measured (b3-fixes, a guest that reads nothing, 512 x 1 MiB writes): the page took 6,295,552 bytes (4 MiB + the 1 MiB ring + 1 MiB the guest's virtio-console driver had buffered), refused the rest in 0.2 s, nothing dropped; the service Worker's heap in Chromium stayed at 18 MiB (540 MiB before the fix).

Test runs after the fixes

Each under the boot lock, one browser per run, PORT=8796, canary 0.61-0.73 s around every run.

| | Chromium | Firefox | WebKit | |---|---|---|---| | bun test test/unit | 241 pass, 2 skip (the review's opt-in file), 0 fail; with WMD_WEB_B3ADV=1 243 pass, 0 fail | | | | b3-fixes (torture on the default options, ENOSPC on the default options, the loose record, F2 cap, F3 flood, default) | 6 passed | 6 passed | 6 passed (F3 at the second run: the first failed my own bound of > 500 refused writes with 498; now > 480) | | chan.spec | 10 passed, 1 failed (hvc0: problem 1, the guest's IRQ storm), 1 skipped (UART) | same | same | | the review's rings (b3-adversarial -g rings, 7 tests) | 7 passed | 7 passed | 7 passed | | the review's guests (10 tests) | 9 passed, 1 failed: ENOSPC with cache=loose (F1: cannot pass, above) | same | same | | boot.spec + jit.spec | 14 passed, 1 skipped (amd64) | same | same |

  • The control channel's 50 MiB: CRC-32 equal, sendWaits 0 in all three (F4c, F4d). The leak check: 10 Workers made, all terminated, and no worker target left in Chromium; with chanSvc?.terminate() removed from finish() it fails (not terminated: [{"name":"wmd-chan"}]) (F4b).
  • The review's ring tests after the fixes: the guest exits at random moments, last thread back 207 / 207 / 209 ms after (was up to 1,010 ms); the service killed, 206 / 218 / 211 ms (was 208 / 238 / 216); the 512 MiB console flood, service Worker heap 23 MiB in Chromium (was 540 MiB).
  • alpine and container in chan.spec now mount with cache=mmap (the default): proof ls -R 1.2 s and ls -R / in the chroot 4.4 s in Chromium (3.56 s with cache=loose in the P-B4 table), container B4-DONE at 26.8 s (25.4 s).

Problems the re-runs found, all fixed and re-run:

  1. The service could be held in one step (chan-services.js #pump): it read a channel for as long as the guest kept its ring full. With the control stub now computing a CRC-32, a guest writing 1 GiB to the control channel through a 1 MiB ring kept one step() reading 180 MB (572 ms in the unit test), so the service took its messages late: the review's kill test saw the page learn of a die/crash 1.7-3.8 s late (3 of 12, all on 1 MiB rings). Now one channel reads at most STEP_READ_BUDGET (1 MiB) per step (unit: 1 MiB, 6 ms).
  2. The review's ring harness posted console input past the new bound (it plays the page and posted straight to the service, 4 MiB ahead of the guest plus one chunk): the service dropped the excess and the 8-channel test's console reader got 5.5 of 16 MiB. The harness now takes input through consoleInputGate, as vm.consoleWrite does (the flood test still posts past it, which is what tests the service's own bound).
  3. cache=mmap and O_APPEND (F1 above): the container torture failed conc.append until the server honoured O_APPEND.
  4. WebKit ended the page in the F1 measurement's cache=loose 100,000-file step at 2 vCPUs (F1 above); the review's own 100,000-file test (1 vCPU) passed in all three.

What is left:

  • The review's cache=loose ENOSPC test still fails, by design: under cache=loose the cap cannot reach write(2). A host that mounts with cache=loose (the native engine's run containers) must not set a cap it relies on, or must accept that writes past it are lost with only fsync/close reporting it.
  • cache=mmap gives up the dentry/attribute cache: metadata-heavy work is slower (the F1 table); an attribute cache with revalidation (cache=loose for metadata only) is not something Linux 6.1's v9fs offers.
  • The cap counts file contents only (names, inodes, xattrs are not charged).
  • hvc0 (problem 1) and the clock / entropy notes (problem 2) are unchanged.

P-B4: the 9P server (core)

src/fs/ is a transport-agnostic 9P2000.L server over an overlay of wmd packs (the read-only lowers) under an in-memory upper: new NinePServer(new Overlay({layers, at: 'rootfs'})).handle(frame) → reply, synchronous, so it can sit behind P-B3's shared-memory channel. The pack reader is checked entry by entry against wmd's Go pack.Reader, and the server frame by frame against wmd's Go 9P server (internal/engine/ninep over memfs) on the same packs, alpine:3.20 included. It has not run in a browser or under a guest yet. Format coverage, layering rules, messages, the known differences from the Go server, the independent review (F1–F6) and the numbers are in docs/9p.md.

Integration of P-B1, P-B2 and P-B4 (branch wmd-web-int, 2026-10-05)

wmd-web (P-B1 adversarial fixes) and wmd-web-9p (P-B4 9P core) merged into the P-B2 branch. Everything from both sides is kept: B1's abort, release, watchdog, throttle detector, panic exit, host-call counters, realtime clock and --ram check; B2's JIT door, hub sharing under JSPI, 32 MiB stdio rings with drop markers.

Two things broke in the merge and were fixed:

  • Synthetic guests trapped. The worker attached the JIT door to every module, and the P-B1 review's synthetic guests (test/e2e/review-synth/*.wat) have no vm_helper_* exports (vm.wasm has no export vm_helper_0). A module now gets a door only if it imports wmd.jit_load.
  • Firefox kept a reloaded page's boot memory. B2's test page held the running handle on window.wmd.vm; after three mid-boot reloads RSS stayed 1.3 GiB higher even after GC (B1's page: back to base). The page now holds the handle in a WeakRef (window.wmd.vm still works while a boot runs).

The stubbed jit_* imports that B1's counters were built for only exist without a door now: bootWmd({jit: false}) / ?door=0. B1's three stubbed-door tests ask for it explicitly.

Suites on the merged branch (one run each, under the lock, canary 597–655 ms except one contended stretch that was only used for A/B memory runs):

| | Chromium | Firefox | WebKit | |---|---|---|---| | bun test test/unit | 197 pass (bun) | | | | boot.spec adversarial.spec (the filter also picks the 3 no-guest storm tests of b2-adversarial) | 11/11; rerun 10/11* | 11/11 | 10/11 (reload x3 memory: WebKit, same on B1) | | fixes.spec b2-fixes.spec throttle.spec (skips: headed throttle, opt-in 3b, Chromium-only GC test) | 16/16 | 15/15 | 15/15 | | review-errors | 27/28 (--cpus 64 interp) | 27/28 (same) | 27/28 (same) | | review-lifecycle review-smp | 11/11 | 8/9 ("close a tab": no Worker close events) | 7/9 ("close a tab"; sequential boots: WebKit's lazy return, also fails on B1) | | jit.spec jit-stress | 20/20 | 18/20 (per-Worker stress: ~16,300-copy limit, share=0) | 20/20 | | b2-adversarial (WMD_WEB_B2ADV=1) | 12/12 | 12/12 | 12/12 |

* "reload during kernel load" failed 2 of 12 runs on Chromium: the test's page.evaluate after the page's own location.reload() lands in the destroyed context. It passed 4 of 4 on B1 alone and 6 of 6 in the next merged run.

Proof boots on the merged branch (ms, PROOF-DONE from the bootWmd call):

| | Chromium | Firefox | WebKit | |---|---|---|---| | interp (P-B1 boot) | 42,207 / 39,999 | 53,798 / 52,039 | 44,761 | | jit, 1 vCPU | 5,842 | 6,092 | 5,743 | | jit, 8 vCPUs: copies / RSS peak | 232 / 1,224 MiB (5,523) | 234 / 1,853 MiB (5,867) | 232 / 1,400 MiB (5,580) |

Adversarial checks (PROOFS.md P-B1)

Independent review (test/adversarial-results.txt, commit b819865e) and fixes

The review's specs are test/e2e/review-*.spec.js; the tests for the fixes are test/e2e/fixes.spec.js, the reload tests in adversarial.spec.js, test/e2e/throttle.spec.js and test/unit/monitor.test.js, gate.test.js. Each fix's test was run against the pre-fix host (Chromium, commit b819865e with the new specs) and failed there, except where noted.

| Finding | Fix | Status | |---|---|---| | AbortSignal fired while vm.wasm compiles is never seen; boot keeps running | src/boot.js `bootW