8.4 KiB
The VFS wire protocol
Status: built and spoken today between
runtime.fs(the client) and the filesystem BACKENDS (the FAT server). The mount router lives in the kernel (system/kernel/vfs.zig):fs_resolveroutes a path and either serves it directly (the read-only /system initrd mount, viafs_node) or redirects the caller to the owning backend's endpoint plus the rewritten mount-relative path — after which the client speaks THIS protocol to the backend, unchanged. The Zig source of truth issystem/vfs-protocol.zig(thevfs-protocolmodule), whose unit tests pin the sizes and values below. This page is the language-neutral wire specification of that contract — what a Rust or C client implements (vdso.md explains why the IPC protocols, not the syscall numbers, are danos's public ABI).
Transport
A VFS exchange is one synchronous IPC rendezvous (ipc_call,
docs/ipc.md): the client sends one message and blocks; the server replies
with one message. The endpoint comes from the kernel's fs_resolve — which
also hands back the path rewritten relative to the mount — not from a
registry lookup. (Service id 1, the old userspace router, is retired.)
- A message is at most 256 bytes (
message_maximum). - A request is a fixed 32-byte Request header followed by an inline
payload of at most 224 bytes (
maximum_payload) — a path, or write bytes. There is no multi-message request: paths and single reads/writes must fit, and larger transfers loop (see read / write). - A reply is a fixed 24-byte Reply header followed by an inline payload —
read bytes, a
FileStatus, or aDirectoryEntry. - All integers are little-endian; layouts are C layout for x86-64
(
extern struct), offsets given below so nothing need be inferred.
The kernel resolves NAMES (the mount table) but never parses these messages — it moves the bytes; file state is entirely the backend's affair. With clients holding backend node ids directly, a backend records each open handle's owner and sweeps a dead client's handles via the published process exit events.
Request header — 32 bytes
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | operation |
an Operation value (below) |
| 4 | 4 | — | padding |
| 8 | 8 | node |
the server-side open-node id from a prior open; 0 for path-based operations |
| 16 | 8 | offset |
byte position for read/write; entry index (cursor) for readdir; else 0 |
| 24 | 4 | len |
payload length for path/write operations; requested byte count for read |
| 28 | 4 | flags |
open flags (below); else 0 |
Reply header — 24 bytes
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | status |
0 = success, negative = failure (signed) |
| 4 | 4 | — | padding |
| 8 | 8 | node |
the new open-node id (for open); else 0 |
| 16 | 4 | len |
reply payload length in bytes |
| 20 | 4 | — | padding |
On failure the router replies status = -1; a mounted backend's negative
status is forwarded to the client verbatim. A richer errno vocabulary is
future work — clients must treat any negative status as failure, not match
on -1.
Operations
Values are append-only and never renumbered (the same evolution rule every
danos protocol follows); an unrecognised operation gets a status = -1
reply.
| value | operation | request payload | reply |
|---|---|---|---|
| 0 | open |
the path (len = its length), flags as below |
node = open-node id |
| 1 | close |
— (node set) |
status only |
| 2 | read |
— (node, offset, len = wanted count) |
len bytes read, payload = the bytes; len 0 at end of file |
| 3 | write |
the bytes (node, offset, len = count) |
len = bytes accepted (may be short — loop) |
| 4 | status |
— (node set) |
payload = FileStatus (24 bytes) |
| 5 | readdir |
— (node = a directory, offset = cursor) |
payload = one DirectoryEntry + name; len 0 at end |
| 6 | mount |
the mount-point path; the backend endpoint rides as the call's capability | status only |
| 7 | unmount |
the mount-point path | status only |
| 8 | mkdir |
the path | status only |
| 9 | unlink |
the path | status only |
| 10 | rename |
old path, one 0x00, new path (len = total) |
status only |
Notes per operation:
- open — paths are absolute (
/mnt/usb/notes.txt) or bare names (greeting); bare names resolve in the VFS's flat ramfs, absolute paths route through the mount table (below). The returnednodeis an id in the router's open table; clients never see a backend's own ids. - read / write — a single exchange moves at most 224 bytes
(
maximum_payload); the client loops, advancingoffsetby the returnedlen, until done (read) or the slice is written (write). Awritereply shorter than requested is progress, not an error; alenof 0 means no forward progress — stop rather than spin. - readdir —
offsetis a cursor: the entry index, not a byte position. Each call returns exactly one entry; the client increments the cursor by 1. A reply withlen0 is end-of-directory. The directory must have been opened with thedirectoryflag. - mount / unmount — RETIRED from the wire: mounting is the
fs_mountsyscall now (a filesystem server passes its endpoint handle; possession is the capability, exactly the trust of the old cap-passing op). The op numbers stay reserved. Mount-prefix semantics are unchanged: prefixes match at path boundaries only (/mnt/usbnever captures/mnt/usbextra), the longest matching prefix wins, and an optional backend-side rewrite prefix maps a mount into the backend's namespace (fat serves/mnt/usbfrom its volume root and/varfrom its/varsubtree). - rename — same-directory rename only (the router requires old and new to resolve under one mount).
Open flags
Bitwise OR in Request.flags, meaningful for open only:
| bit | name | meaning |
|---|---|---|
| 1 | create |
create the file if it does not exist |
| 2 | directory |
open a directory node for readdir rather than a file |
| 4 | truncate |
truncate an existing file to zero length on open (replace, don't overwrite in place) |
FileStatus — 24 bytes (the status reply payload)
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 8 | size |
file size in bytes |
| 8 | 4 | kind |
a NodeKind value |
| 12 | 4 | — | padding |
| 16 | 8 | mtime |
modification time, Unix epoch seconds UTC; 0 if the backend keeps none |
DirectoryEntry — 16 bytes + name (the readdir reply payload)
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | kind |
a NodeKind value |
| 4 | 4 | name_len |
length of the name that follows |
| 8 | 8 | size |
the entry's size in bytes |
| 16 | name_len |
name | the entry's name, not NUL-terminated |
NodeKind
Aligned to the FSH file-type table (docs/danos-file-system-hierarchy-FSH.md):
| value | kind |
|---|---|
| 0 | regular file |
| 1 | directory |
| 2 | character device |
| 3 | block device |
| 4 | symbolic link |
| 5 | fifo |
| 6 | socket |
Clients should map unknown values to regular rather than reject — the table can grow.
Lifetimes and trust
Open-node ids live in the server. A client that dies without closing leaks nothing permanently: the VFS subscribes to the kernel's published process-exit events (docs/process-lifecycle.md) and releases a dead client's handles, closing forwarded backend nodes best-effort. Ids are plain integers, not capabilities — the VFS trusts its callers with each other's ids today, which is acceptable while every client is part of the system image and worth revisiting (per-client id namespaces) before third-party binaries arrive.
Evolution rules
What a non-Zig implementation may rely on, and what it must not:
- Operation values, flag bits,
NodeKindvalues, and struct layouts are append-only and frozen once shipped — the unit tests inprotocol.zigpin them exactly so a refactor can't silently move them. - The 256-byte message ceiling is a property of the current IPC transport,
not a promise; clients should read
maximum_payload-shaped limits from the reply lengths they actually get (loop-until-done), not hard-code 224. - Negative statuses beyond -1 will appear (an errno vocabulary); success is exactly 0.