Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Proposal: Running capOS in the Browser (WebAssembly, Worker-per-Process)

How capOS goes from “boots in QEMU” to “boots in a browser tab,” with each capOS process executing in its own Web Worker and the kernel acting as the scheduler/dispatcher across them.

The goal is a teaching and demo target, not a production runtime. It should preserve the capability model — typed endpoints, ring-based IPC, no ambient authority — while replacing the hardware substrate (page tables, IDT, preemptive timer, privilege rings) with browser primitives (Worker boundaries, SharedArrayBuffer, Atomics.wait/notify).

Depends on: Stage 5 (Scheduling), Stage 6 (IPC) — the capability ring is the only kernel/user interface we want to port. Anything still sitting behind the transitional write/exit syscalls must migrate to ring opcodes first.

Complements: userspace-binaries-proposal.md (language/runtime story), service-architecture-proposal.md (process lifecycle). A browser port stresses both: the runtime must build for wasm32-unknown-unknown, and process spawn becomes “instantiate a Worker” rather than “map an ELF.”

Non-goals:

  • Running the existing x86_64 kernel unmodified in the browser. That’s a separate question (QEMU-WASM / v86) and is a simulator, not a port.
  • Emulating the MMU, IDT, or PIT in WASM. The whole point is to replace them with primitives the browser already gives us for free.
  • Any persistence, networking, or storage beyond what a hosted demo needs.

Current State

capOS is x86_64-only. Arch-specific code lives under kernel/src/arch/x86_64/ and relies on:

MechanismFileBrowser equivalent
Page tables, W^X, user/kernel splitmem/paging.rs, arch/x86_64/smap.rsWorker + linear-memory isolation (structural)
Preemptive timer (PIT @ 100 Hz)arch/x86_64/pit.rs, idt.rssetTimeout/MessageChannel + cooperative yield
Syscall entry (SYSCALL/SYSRET)arch/x86_64/syscall.rsDirect Atomics.notify on ring doorbell
Context switcharch/x86_64/context.rsNone — each process is its own Worker, OS schedules
ELF loadingelf.rs, main.rsWebAssembly.instantiate from module bytes
Frame allocatormem/frame.rsmemory.grow inside each instance
Capability ringcapos-config/src/ring.rs, cap/ring.rsReused unchanged — shared via SharedArrayBuffer
CapTable, CapObjectcapos-lib/src/cap_table.rsReused unchanged in kernel Worker

The capability-ring layer is the only stable interface that survives the port intact. Everything below cap/ring.rs is arch work; everything above is schema-driven capnp dispatch that doesn’t care about the substrate.


Architecture

flowchart LR
    subgraph Tab[Browser Tab / Origin]
        direction LR
        Main[Main thread<br/>xterm.js, UI, loader]
        subgraph KW[Kernel Worker]
            Kernel[capOS kernel<br/>CapTable, scheduler,<br/>ring dispatch]
        end
        subgraph P1[Process Worker #1<br/>init]
            RT1[capos-rt] --> App1[init binary]
        end
        subgraph P2[Process Worker #2<br/>service<br/>spawned by init]
            RT2[capos-rt] --> App2[service binary]
        end
        SAB1[(SharedArrayBuffer<br/>ring #1)]
        SAB2[(SharedArrayBuffer<br/>ring #2)]
        Main <-->|postMessage| KW
        KW <-->|SAB + Atomics| SAB1
        KW <-->|SAB + Atomics| SAB2
        P1 <-->|SAB + Atomics| SAB1
        P2 <-->|SAB + Atomics| SAB2
        P1 -.spawn.-> KW
        KW -.new Worker.-> P2
    end

One Worker per capOS process. Each process is a WASM instance in its own Worker, with its own linear memory. Cross-process access is structurally impossible — postMessage and shared ring buffers are the only channels.

Kernel in a dedicated Worker. Not on the main thread: the main thread is reserved for UI (terminal, loader, error display). The kernel Worker owns the CapTable, holds the Arc<dyn CapObject> registry, dispatches SQEs, and maintains one SharedArrayBuffer per process for that process’s ring. It directly spawns init; all further processes are created via the ProcessSpawner cap it serves.

Capability ring over SharedArrayBuffer. The existing CapRingHeader/CapSqe/CapCqe layout in capos-config/src/ring.rs already uses volatile access helpers for cross-agent visibility. Mapping it onto a SharedArrayBuffer is a change of backing store, not of protocol. Both sides see the same bytes; Atomics.load/Atomics.store replace the volatile reads on the host side; on the Rust/WASM side the existing read_volatile/ write_volatile lower to plain atomic loads/stores under wasm32-unknown-unknown with the atomics feature enabled.

cap_enter becomes Atomics.wait. The process Worker calls Atomics.wait on a doorbell word in the SAB after publishing SQEs. The kernel Worker (or its scheduler tick) calls Atomics.notify after producing completions. That is exactly the io_uring-inspired “syscall-free submit, blocking wait on completion” the ring was designed around — the browser happens to give us the primitive for free.

No preemption inside a process. A Worker runs to completion on its event loop turn; the kernel can’t interrupt it. This is fine: each process is single-threaded in its own isolate, and the scheduler only needs to wake the next process after Atomics.wait, not forcibly remove the running one. This is closer to a cooperative capnp-rpc vat model than to the current timer-preempted kernel, and matches what the capability ring already assumes.


Mapping capOS Concepts to WASM/Browser

Process isolation

The Worker boundary replaces the page table. Two capOS processes cannot observe each other’s linear memory, cannot jump into each other’s code (code is out-of-band in WASM — not addressable as data), and cannot share globals. The SharedArrayBuffer containing the ring is the only intentional shared region, and it is created by the kernel Worker and transferred to the process Worker at spawn time.

No W^X enforcement is needed within a Worker because WASM has no writable code region to begin with — WebAssembly.Module is validated and immutable. The MMU’s job is done by the WASM type system and validator.

Address space / memory

Each Worker’s WASM instance has one linear memory. capos-rt’s fixed heap initialization uses memory.grow instead of VirtualMemory::map. The VirtualMemory capability still exists in the schema, but its implementation in the browser port is a thin wrapper over memory.grow with bookkeeping for “logical unmap” (zeroing + tracking a free list — WASM doesn’t return pages to the host).

Protection flags (PROT_READ/PROT_WRITE/PROT_EXEC) become no-ops with a documented caveat in the proposal: the browser port does not enforce intra-process protection. Cross-process protection is structural and stronger than the native build.

Syscalls

The three transitional syscalls (write, exit, cap_enter) collapse to:

  • write — already slated for removal once init is cap-native. In the browser port, do not implement it at all. Force the port to drive the existing cap-native Console ring path, which forces the rest of the tree to be cap-native too. A forcing function, not a cost.
  • exitpostMessage({type: 'exit', code}) to the kernel Worker, which terminates the Worker via worker.terminate() and reaps the process entry.
  • cap_enterAtomics.wait on the ring doorbell after publishing SQEs, with a waitAsync variant for cooperative mode if we ever want to avoid blocking the Worker’s event loop.

Scheduler

Round-robin is gone; the browser scheduler is the OS scheduler. The kernel Worker’s “scheduler” is reduced to:

  1. A poll loop that drains each process’s SQ (the existing cap/ring.rs::process_sqes logic, called on every notify or on a setTimeout(0) tick).
  2. A completion-fanout step that pushes CQEs and Atomics.notifys the target Worker.

No context switch, no run queue, no per-process kernel stack. The code deleted here is exactly the code that smp-proposal.md says needs per-CPU structures — an orthogonal win: the browser port has no SMP problem because each process is structurally on its own agent.

Process spawning

The kernel Worker spawns exactly one process Worker directly — init — with a fixed cap bundle: Console, ProcessSpawner, FrameAllocator, VirtualMemory, BootPackage, and any host-backed caps (Fetch, etc.) granted to it.

// Kernel Worker bootstrap
const initMod = await WebAssembly.compileStreaming(fetch('/init.wasm'));
const initRing = new SharedArrayBuffer(RING_SIZE);
const initWorker = new Worker('process-worker.js', {type: 'module'});
kernel.registerProcess(initWorker, initRing, buildInitCapBundle());
initWorker.postMessage(
    {type: 'boot', mod: initMod, ring: initRing, capSet: initCapSet,
     bootPackage: manifestBytes},
    [/* transfer */]);

All further processes come from init invoking ProcessSpawner.spawn. ProcessSpawner is served by the kernel Worker; each invocation:

  1. Compiles the referenced binary bytes (WebAssembly.compile over the NamedBlob from BootPackage).
  2. Creates a new Worker and a SharedArrayBuffer for its ring.
  3. Builds the child’s CapTable from the ProcessSpec the caller passed, applying move/copy semantics to caps transferred from the caller’s table.
  4. Returns a ProcessHandle cap.

Init composes service caps in userspace: hold Fetch, attenuate to per-origin HttpEndpoint, hand each child only the caps its ProcessSpec names. Same shape as native after Stage 6.

Host-backed capability services

Some capabilities in the browser port are implemented by talking to the browser rather than to hardware. Fetch and HttpEndpoint — drafted in service-architecture-proposal.md — are the canonical example. On native capOS they run over a userspace TCP/IP stack on virtio-net/ENA/gVNIC. In the browser port, the service process is replaced by a thin implementation living in the kernel Worker (or a dedicated “host bridge” Worker) that dispatches each capnp call by calling fetch / new WebSocket and returning the response as a CQE. The attenuation story is unchanged: Fetch can reach any URL, HttpEndpoint is bound to one origin at mint time, derived from Fetch by a policy process.

This is not a back door. The capability is granted through the manifest exactly as on native. Processes without the cap cannot reach the host’s network, cannot discover it, and cannot forge one. The only difference from native is the implementation of the service behind the CapObject trait — same schema, same TYPE_ID, same error model.

The same pattern applies to anything else the browser provides natively. Candidate future interfaces (no schema yet, mentioned so the port is considered when they are designed):

  • Clipboard over navigator.clipboard
  • LocalStorage / KvStore over IndexedDB (natural Store backend for the storage proposal in the browser)
  • Display / Canvas over an OffscreenCanvas posted back to the main thread
  • RandomSource over crypto.getRandomValues — trivial but needs a cap rather than a syscall

Other drafted network interfaces — TcpSocket, TcpListener, UdpSocket, NetworkManager from networking-proposal.md — do not have a clean browser mapping. The browser exposes no raw-socket primitives, so these caps cannot be served in the browser port at all. Applications that need networking in the browser must go through Fetch/HttpEndpoint, and the POSIX shim’s socket path must detect the absence of NetworkManager and route connect("http://...") through Fetch instead (or fail closed for other schemes). CloudMetadata from cloud-metadata-proposal.md is simply not granted in the browser; there is no cloud instance to describe.

Each host-backed cap is opt-in per-process via the manifest; each has a native counterpart that the schema is already the contract for. This is a substantial point in favor of the port: host-provided services slot into the existing capability model without widening it.

CapSet bootstrap

The read-only CapSet page at CAPSET_VADDR is replaced by a structured-clone payload in the initial postMessage. capos-rt::capset::find still parses the same CapSetHeader/CapSetEntry layout, just out of a Uint8Array placed at a known offset in the process’s linear memory by the boot shim.


Binary Portability

Source-portable, not binary-portable. An ELF built for x86_64-unknown-capos does not run; the same source rebuilt for wasm32-unknown-unknown (with the atomics target feature) does, provided it stays inside the supported API surface.

Rust binaries on capos-rt

Port cleanly:

  • Any binary that uses only capos-rt’s public API — typed cap clients (ConsoleClient, future FileClient, etc.), ring submission/completion, CapSet::find, exit, cap_enter, alloc::*.
  • Pure computation, core/alloc containers, serde/capnp message building.

Do not port:

  1. Anything that uses core::arch::x86_64, inline asm!, or global_asm!.
  2. Binaries with a custom _start or a linker script baking in 0x200000. capos-rt owns the entry shape; the wasm entry is set by the host (WebAssembly.instantiate + an exported init), so the prologue differs.
  3. #[thread_local] relying on FS base until the wasm TLS story is decided (per-Worker globals, or the wasm threads proposal’s TLS).
  4. Code that assumes a fixed-size static heap region and reaches it with raw pointers. The wasm arch uses memory.grow; alloc::* hides this, unsafe { &mut HEAP[..] } does not.
  5. Anything that still calls the transitional write syscall shim — the browser build deliberately omits it.

Binaries mixing target features across the workspace produce silently- broken atomics. A single rustflags set for the browser build is required.

POSIX binaries (when the shim lands)

The POSIX compatibility layer described in userspace-binaries-proposal.md Part 4 sits on top of capos-rt. If capos-rt builds for wasm, the shim builds for wasm, and well-behaved POSIX code rebuilt for a wasm-targeted libcapos (clang --target=wasm32-unknown-unknown + our libc) ports too.

Ports cleanly:

  • Pure computation, string/number handling, data-structure libraries.
  • stdio over Console / future File caps.
  • malloc/free, C++ new/delete, static constructors.
  • select/poll/epoll implemented over the ring (ring CQEs are exactly the event source these APIs want).
  • posix_spawn over ProcessSpawner — spawning a new process becomes “instantiate a new Worker,” which is the native shape of the browser anyway.
  • Networking via Fetch/HttpEndpoint (drafted in service-architecture-proposal.md) if the manifest grants the cap. The browser port serves these against the host’s fetch/WebSocket — not ambient authority, because only processes granted the cap can invoke it. Raw AF_INET/AF_INET6 sockets via the TcpSocket/NetworkManager interfaces in networking-proposal.md are not available in the browser (no raw-socket primitive); POSIX networking code wants URLs in practice, and a libc shim can map getaddrinfo+connect+write over Fetch/HttpEndpoint for the HTTP(S) case, failing closed otherwise.

Does not port without new work, possibly ever:

  1. fork. Cannot clone a Worker’s linear memory into a new Worker and resume at the fork call site — there is no COW, no MMU, no way to duplicate an opaque WASM module’s mid-execution state. This is the same reason Emscripten/WASI don’t support fork. POSIX programs that fork-then-exec can be rewritten to posix_spawn; programs that fork-for-concurrency (Apache prefork, some Redis paths) cannot.
  2. Signals. No preemption inside a Worker means no asynchronous signal delivery. SIGALRM, SIGINT, SIGSEGV all need cooperative polling at best; kill(pid, SIGKILL) maps to worker.terminate() and nothing finer. setjmp/longjmp works within a function call tree; siglongjmp out of a signal handler does not exist.
  3. mmap of files with MAP_SHARED. WASM linear memory is not file-backed and cannot be. MAP_PRIVATE | MAP_ANONYMOUS works trivially (it’s just memory.grow + a free list). File-backed mappings require a userspace emulation that reads on fault and writes back on unmap — workable for small files, a lie for the memory- mapped-database case.
  4. Threads without the wasm threads proposal. pthreads over Workers sharing a memory is the only implementation strategy, and it requires the wasm atomics/bulk-memory/shared-memory feature set plus careful runtime support. Single-threaded POSIX code works now; multithreaded POSIX code needs the in-process-threading track from the native roadmap and its wasm counterpart.
  5. Address-arithmetic tricks. Wasm validates loads/stores against the linear-memory bounds. Code that relies on unmapped trap pages (guard pages, end-of-allocation sentinels) or on specific virtual addresses fails.
  6. dlopen. A wasm module is immutable after instantiation. Dynamic loading requires loading a second module and linking via exported tables — possible with the component model, nowhere near drop-in dlopen. Static linking is the pragmatic answer.

Rough guide: if a POSIX program compiles cleanly under WASI and uses only WASI-supported syscalls, it will almost certainly port to capOS-on-wasm with the shim, because the constraints overlap. If it needs features WASI doesn’t support (fork, signals, shared mmap), the capOS browser port will not magically fix that — the limitations come from the substrate, not from the POSIX shim’s completeness.


Build Path

Three new cargo targets, no workspace restructuring required:

  1. capos-lib on wasm32-unknown-unknown. Already no_std + alloc, no arch-specific code. Should build as-is; verify under cargo check --target wasm32-unknown-unknown -p capos-lib.

  2. capos-config on wasm32-unknown-unknown. Same — pure logic, the ring structs and volatile helpers are portable.

  3. capos-rt on wasm32-unknown-unknown with atomics feature. The standalone userspace runtime currently hard-codes x86_64 syscall instructions. Introduce an arch module split:

    • arch/x86_64.rs (existing syscall.rs contents)
    • arch/wasm.rs (new — Atomics.wait via core::arch::wasm32::memory_atomic_wait32, exit via host import)

    Gate at the syscall boundary, not deeper; the ring client above it is arch-agnostic.

  4. Demos on wasm32-unknown-unknown. Same arch split applied via capos-rt. No per-demo changes expected if the split is clean.

The kernel does not build for wasm. Instead, a new crate capos-kernel-wasm/ (peer to kernel/) reuses capos-lib’s CapTable and capos-config’s ring structs and implements the dispatch loop against JS host imports for Worker management. It is, deliberately, not the same kernel binary. Trying to build kernel/ for wasm would pull in IDT/GDT/paging code that has no meaning in the browser.


Phased Plan

Phase A: Port the pure crates

  • Verify capos-lib, capos-config build clean on wasm32-unknown-unknown. CI job: cargo check --target wasm32-unknown-unknown -p capos-lib -p capos-config.
  • Add a host-side ring-tests-js harness that exercises the same invariants as tests/ring_loom.rs but with a real JS producer and a Rust/wasm consumer, both sharing a SharedArrayBuffer. Proves the volatile access helpers are portable before anything else depends on them.

Phase B: capos-rt arch split

  • Introduce capos-rt/src/arch/{x86_64,wasm}.rs behind a #[cfg(target_arch)].
  • Rewire syscall/ring/client to call through the arch module.
  • Add make capos-rt-wasm-check target. Existing make capos-rt-check stays for x86_64.

Phase C: Kernel Worker + init

  • capos-kernel-wasm/ with a Console capability that renders to xterm.js via postMessage back to the main thread.
  • Kernel Worker spawns init. Init prints “hello” through Console and exits.

Phase D: ProcessSpawner + Endpoint

  • ProcessSpawner served by the kernel Worker, granted to init.
  • Init parses its BootPackage and spawns the endpoint-roundtrip and ipc-server/ipc-client demos via ProcessSpawner.spawn. These stress capability transfer across Workers: does a cap handed from A to B via the ring land correctly in B’s ring, and does B’s subsequent invocation route back to the right holder?
  • This phase turns the port into a validation surface for the capability-transfer and badge-propagation invariants in docs/authority-accounting-transfer-design.md, and a second implementation of the Stage 6 spawn primitive.

Phase E: Integration with demos page

  • Hosted page at a project URL; xterm.js terminal; selector for which demo manifest to boot.
  • Serve .wasm artifacts as static assets.

Security Boundary Analysis

The browser port changes what is trusted and what is verified. Summary:

BoundaryNative (x86_64)Browser (WASM-Workers)
Process ↔ processPage tables + ringsWorker agents + SAB (structural)
Process ↔ kernelSyscall MSRs + SMEP/SMAPpostMessage + validated host imports
Code integrityW^X + NXWASM validator + immutable Module
Capability forgeryKernel-owned CapTableKernel-Worker-owned CapTable
Capability transferRing SQE validated in kernelRing SQE validated in kernel Worker — same code path

The capability-forgery story is the same in both: an unforgeable 64-bit CapId is assigned by the kernel and can only be resolved through the kernel’s CapTable. A process Worker cannot synthesize a valid CapId because it never sees the CapTable; it only sees SQEs it submits and CQEs it receives. This property is what makes the port worth doing — the capability model is preserved exactly.

What weakens: no SMAP/SMEP equivalent, but also no corresponding attack surface (the “kernel” Worker has no pointer into process memory; it can only copy bytes out of the shared ring). No DMA problem. No side-channel parity with docs/dma-isolation-design.md — Spectre/meltdown in the browser is the browser’s problem, mitigated by site isolation and COOP/COEP.

Required headers: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corpSharedArrayBuffer is gated on these. A hosted demo page must set them.


What This Port Buys Us

  1. Shareable demos. A URL that boots capOS in ~1s, with no QEMU, no local install. Valuable for documentation and recruiting.
  2. A second substrate for the capability model. If the cap-transfer protocol has a bug, reproducing it under Workers (single-threaded, deterministic scheduling) is much easier than under SMP x86_64. A second implementation of the dispatch surface is a correctness asset.
  3. Forcing function for write syscall removal. The browser port cannot support the transitional write path without importing host I/O as a back door, which is exactly the ambient authority we want to avoid. Shipping a browser demo at all requires finishing the migration to the Console capability over the ring.
  4. Teaching surface. Workers give a much clearer visual of “one process, one memory, one cap table” than a bare-metal kernel ever will. The isolation story renders in the DevTools panel.

What It Does Not Buy Us

  1. Not a validation surface for the x86_64 kernel. Page tables, IDT, context switch, SMP — none of that runs. Bugs in those subsystems will not appear in the browser build.
  2. Not a performance story. WASM + Workers + SAB is slower than native QEMU-KVM for the parts it does overlap on, and does not exercise the hardware features capOS eventually cares about (IOMMU, NVMe, virtio-net).
  3. Not a path to “capOS on Cloudflare Workers” or similar. Cloudflare’s runtime is a single isolate per request, no SAB, no threads — a different environment that would need its own proposal.

Open Questions

  1. Do we ship one capos-kernel-wasm crate, or does the kernel Worker run plain JS that imports a thin capos-dispatch wasm? JS-hosted kernel is simpler (no second wasm toolchain for the kernel side) but duplicates cap-dispatch logic. Preferred: Rust/wasm kernel Worker reusing capos-lib — dispatch code stays single-sourced.
  2. How do we surface kernel panics in the browser? Native capOS halts the CPU; the browser equivalent is posting an error to the main thread and tearing down all Workers. Should match the panic = "abort" contract — no recovery attempted.
  3. Do we implement VirtualMemory as a no-op or as a real allocator? No-op is faster to ship; a real allocator over memory.grow exercises more of the capability surface. Lean toward real, gated behind a browser-shim flag so the demo doesn’t silently diverge from the native semantics.
  4. Manifest format: keep capnp, or add JSON for hand-authored demo configs? Keep capnp. The manifest is already the contract; adding a parallel format is exactly the drift the project has been careful to avoid.

Relationship to Other Proposals

  • userspace-binaries-proposal.md — the wasm32 runtime story lives there eventually. This proposal is narrower: just enough runtime to boot the existing demo set in a browser. If the userspace proposal lands a richer runtime first, this one adopts it.
  • smp-proposal.md — structurally irrelevant to the browser port (each Worker is its own agent). The browser port does inform SMP testing, because the cap-transfer protocol under Workers is a cleaner model of “messages cross agents asynchronously” than single-CPU preempted kernels.
  • service-architecture-proposal.md — process spawn in the browser becomes Worker instantiation. The lifecycle primitives (supervise, restart, retarget) map naturally. Live upgrade (live-upgrade-proposal.md) is even more natural under Workers than under in-kernel retargeting — swap the WebAssembly.Module behind a Worker while the ring stays live.
  • security-and-verification-proposal.md — the browser port adds a CI job (wasm builds + JS-side ring tests) but does not change the verification story for the native kernel.