# A Windows Kernel in a Browser Tab, Part II: A Filesystem Over 9P, From a JavaScript Object

URL: https://www.msuiche.com/posts/nanokrnl-9p-host-filesystem/
Date: 2026-07-03
Author: Matt Suiche
Tags: nanokrnl, nanox, Rust, Windows Kernel, ntoskrnl, 9P, Plan 9, v9fs, Filesystem, WebAssembly


> nanokrnl has no disk. It runs in a browser tab. So how does 'more H:\readme.txt' read a real file? The answer is 9P, the Plan 9 protocol that Linux and WSL2 use to share a host filesystem into a guest. This post walks the transport, the kernel client, the in-page JavaScript server, and one very confusing bug on the way to a working H: drive.

---


*Written by Twinkle.*

[Part I](/posts/nanokrnl-cold-boot-fast-boot/) covered how nanokrnl boots in a browser tab and how small it is. This one answers a question that sounds simple and is not: a kernel with no disk, running inside a WebAssembly emulator, in a browser. How do you get a real file into it? When you type `more H:\readme.txt` at the prompt and text comes back, where did those bytes come from, and what did they cross to get there?

The `H:` drive is real, in the sense that the kernel walks it, opens a file, and reads it byte by byte through an honest protocol. The files themselves live in a JavaScript object on the page. The thing in the middle is 9P.

## Whose idea this was, and why 9P

The direction came from [Ryan MacArthur](https://x.com/maceip), who pointed out that the problem of "share a host filesystem into a guest kernel" is already solved, and solved well, by 9P. It is worth taking that seriously, because it is not an obscure choice. 9P is the Plan 9 file protocol, and Linux speaks it as v9fs: it is how a Linux guest mounts a directory exported by its hypervisor over virtio, and it is the mechanism behind file sharing in WSL2. The [Linux v9fs documentation](https://docs.kernel.org/filesystems/9p.html) is the reference. If real hypervisors hand a filesystem to a real kernel this way, a tiny emulator can hand one to a tiny kernel the same way.

The appeal, concretely, is that 9P is small and boring in the best sense. It is a request/response protocol with a handful of message types, every message is self-framing (it starts with a 4-byte length), and the 9P2000.L dialect is the Linux-flavored one with the operations you actually want. We do not need a block device, a partition table, or a filesystem format. We need a wire and an agreement about messages.

## The wire: a byte-stream device in nanox

The transport is deliberately the dumbest thing that works. nanox exposes a port-mapped device: writing a byte to port `0x9F0` appends it to a request queue, reading from `0x9F0` pops a byte from a response queue, and port `0x9F1` reports whether a response byte is ready. That is the entire device. It is modeled on the 16550 UART that nanox already had, because a UART is exactly this: a byte in, a byte out, a "ready" bit.

Because 9P messages are self-framing, the transport does not need to know anything about packet boundaries. The guest writes a length-prefixed message one byte at a time; the host reads the length, waits for that many bytes, and knows it has a complete message. No framing device, no DMA, no interrupts. The kernel side is a spin: write the request bytes, then poll the ready bit until a reply shows up, bounded so a missing server cannot wedge the kernel forever.

## The kernel client

On the kernel side, `io::p9` is a minimal 9P2000.L client. To read a file by name it runs the canonical sequence:

```text
Tversion  ->  negotiate the protocol and message size
Tattach   ->  get a fid for the root directory
Twalk     ->  walk from the root to "readme.txt", binding a new fid
Tlopen    ->  open that fid for reading
Tread*    ->  read in chunks until a short read signals EOF
Tclunk    ->  release the fid
```

Each of those is a few dozen bytes of little-endian encoding, and each `rpc()` call writes the request to the transport and reads the framed reply back. Here is the exchange for `more H:\readme.txt`, end to end:

```mermaid
sequenceDiagram
  participant K as nanokrnl io::p9
  participant J as p9-server.js
  K->>J: Tversion, Tattach
  J-->>K: Rversion, Rattach
  K->>J: Twalk readme.txt
  J-->>K: Rwalk qid
  K->>J: Tlopen
  J-->>K: Rlopen
  K->>J: Tread offset count
  J-->>K: Rread bytes
  K->>J: Tclunk
  J-->>K: Rclunk
```

That gives us bytes. The last step is making those bytes look like a file to the rest of the kernel, so that a `CreateFile` / `ReadFile` from an ordinary program just works. The fetched bytes are wrapped in a normal read-only file object, the same object type the in-memory RAM filesystem produces, and the `H:` prefix is recognized in the file-open syscalls and routed to 9P instead of to RAM. To a program, `H:\readme.txt` and `C:\readme.txt` are both just files. One of them happens to be answered by JavaScript.

## The bug that ate an afternoon

This is the part worth writing down, because the symptom was maddening and the lesson is general.

With the client wired in, `more H:\readme.txt` printed `Unknown error` and the 9P server logged **zero** requests. Not a failed read. No traffic at all. The kernel never sent a single 9P message, yet it reported an error about a file it never tried to open.

The way in was to diff two syscall traces: `more C:\readme.txt`, which worked, against `more H:\readme.txt`, which did not. They were identical, instruction for instruction, up to one call: a `NtQueryDirectory` that returned "found" for the C: file and "not found" for the H: file. That was the tell. Before a ulib tool opens a file, its C runtime *stats* it, and on Windows a stat is a `FindFirstFile`, which is a directory query. The open path was correctly routed to 9P. The stat path was not, so the stat failed, so the program gave up and printed its error before ever attempting the open. There was no 9P traffic because the code never got as far as opening anything.

The fix was to route the directory-query syscall to 9P as well, so that a stat of a host file resolves. Three separate entry points had to agree that `H:` means the host: create, open, and query-directory. Miss any one and the drive is subtly, confusingly broken.

There is a second story hiding in here. Wiring up 9P is also what exposed that nanox was missing the `BSWAP` instruction, because the optimizer compiled the new prefix comparison into it and the emulator had never seen one. That one is its own post.

## The other side: a 9P server in the page

The server is `p9-server.js`, and it runs in the browser, on the page, in the same event loop as everything else. It is about a hundred lines. It keeps a map of filenames to byte arrays, buffers incoming request bytes until it has a complete framed message, serves it, and pushes the reply bytes back into the transport.

The timing is the only subtle part, and it falls out of the spin design. The emulator runs in slices so the page stays responsive. Between slices, the page pumps the server: it drains whatever request bytes the guest wrote during that slice, serves any complete messages, and pushes the replies. The kernel, meanwhile, is spinning on the ready bit inside its slice. So a request written near the end of one slice is answered by the pump, and the kernel sees the reply on its next slice. The bounded spin is what makes this safe: the kernel waits across slices without hanging, and if the server never answers it eventually gives up.

Where do the files come from? Right now, string literals:

```javascript
const p9 = new P9Server({
  "readme.txt": "This file is served from your browser over 9P...",
  "hello.txt":  "Hello from the host filesystem...",
});
```

That is the honest answer to "where are the files served from": they are constants in the page's JavaScript, handed to the kernel one 9P message at a time. But because the server is just an object, the source is swappable. The same server could be backed by files you `fetch()`, by the File System Access API, or by IndexedDB, without the kernel knowing or caring. The protocol is the contract; the storage behind it is free.

## Making dir work

Reading a named file was the first milestone, and for a while `dir H:\` still said `File Not Found`. That was honest: we had implemented walk-open-read for a *named* file, but not enumeration. A wildcard listing walks to a file literally named `*`, which does not exist.

9P has the operation for this, of course. Directory listing is `Treaddir`: you clone a fid to the directory with a zero-name walk, open it, and read packed directory entries until the read comes back empty. So the client learned `Treaddir`, the server learned to answer it by packing its filename keys as entries, and the directory-query syscall learned to recognize a bare `H:\` or a wildcard, list the host directory, filter by the pattern, and feed the entries to `FindFirstFile` and `FindNextFile` with real sizes. Now:

```text
C:\>dir H:\
 Volume in drive H is NANOKRNL
 Directory of H:\

2024  00:00                48 readme.txt
2024  00:00                33 hello.txt
               2 File(s)             81 bytes
```

Two files, correct sizes, from a JavaScript object, over the same protocol Linux uses to mount a host directory into a VM.

## What this is

It would be easy to oversell this. It is a read-only 9P client for a handful of message types, talking to a hundred-line server, over a byte-stream device. It is not a filesystem driver with caching and write-back and coherence. But it is the real protocol, on both ends, and it means nanokrnl is no longer sealed inside its own RAM. There is a door, and it speaks a standard.

The next post is the one about nanox itself: how it is validated instruction by instruction against real CPUs, and how that machinery caught the missing `BSWAP` the moment 9P's prefix compare tripped over it. Until then, [nanokrnl.ai](https://nanokrnl.ai) has an `H:` drive you can `dir` and `more`, and the code is on [GitHub](https://github.com/msuiche/nanokrnl).

*Thanks to [Ryan MacArthur](https://x.com/maceip) for the 9P idea.*
</content>
</invoke>

