12 KiB
The VFS wire protocol
Status: built and spoken today between
file_system(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. Since P4a the contract is expressed throughenvelope.Define(docs/os-development/protocol-namespace.md), so every packet begins with the universal 16-byte prefix and the open-node id rides in it. The Zig source of truth islibrary/protocol/vfs/vfs-protocol.zig(thevfs-protocolmodule), whose unit test pins a sample of 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). - Every packet begins with the 16-byte envelope prefix
(protocol-namespace.md): a
Headeron a request, aStatuson a reply. The prefix is folded, not stacked — the verb and the object being addressed live in it, and no request or reply below repeats either. - A request is the header, then the verb's own fixed part (0–16 bytes), then
an inline tail 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 the status, then the verb's own fixed part, then an inline tail — read bytes, or a directory entry's name.
- 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 — 16 bytes
The envelope's Header, identical in every danos protocol:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | operation |
an Operation value (below); 0–15 are the reserved universal verbs |
| 4 | 4 | — | padding |
| 8 | 8 | target |
the open-node id from a prior open; 0 for open itself and the path-based verbs |
Reply header — 16 bytes
The envelope's Status:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | status |
0 = success, negative = failure (signed) |
| 4 | 4 | — | padding |
| 8 | 4 | len |
reply bytes following this header: the verb's fixed part plus its tail |
| 12 | 4 | — | padding |
A failing backend replies with the status alone (len = 0) and no fixed
part, and that reply reaches the client directly — there is no party between
them on the wire. (Kernel-served paths produce no wire replies at all:
fs_resolve/fs_node failures are syscall register statuses.) The errno
vocabulary is the kernel's, continued by the envelope: ENOENT = 4 is what a
backend answers for anything it cannot find or cannot do, ENOSYS = 10 for a
verb it does not implement, EPROTO = 11 for a packet shorter than the verb
it names. Clients must treat any negative status as failure rather than
matching a particular one.
Operations
Values number from 16 (first_protocol_operation) in declaration order, and
are frozen once shipped. Values 0–15 are the envelope's reserved universal
verbs, which mean the same thing at every provider in the system: describe
(0) answers the protocol's name and version and is implemented by the
envelope itself, so every backend answers it. A verb outside this table is
answered -ENOSYS; it is never a safety check any more, because the
dispatch compares numbers rather than decoding an enum.
Each row's request and reply name the bytes after the 16-byte prefix.
| value | operation | request | tail | reply | reply tail |
|---|---|---|---|---|---|
| 16 | open |
flags (4 bytes, below) |
the path | node (8 bytes) = the open-node id |
— |
| 17 | close |
— | — | — | — |
| 18 | read |
offset (8), len (4) = wanted count |
— | — | the bytes read; Status.len 0 at end of file |
| 19 | write |
offset (8), len (4) = count |
the bytes | count (4) = bytes accepted (may be short — loop) |
— |
| 20 | status |
— | — | FileStatus (24 bytes) | — |
| 21 | readdir |
cursor (8) |
— | one DirectoryEntry (16 bytes) | the name |
| 22 | mount |
— | the mount-point path; the backend endpoint rides as the call's capability | — | — |
| 23 | unmount |
— | the mount-point path | — | — |
| 24 | mkdir |
— | the path | — | — |
| 25 | unlink |
— | the path | — | — |
| 26 | rename |
— | old path, one 0x00, new path |
— | — |
| 27 | bind |
— | the contract name; the provider's endpoint rides as the call's capability | — | — |
Notes per operation:
- open — the path is the mount-relative path
fs_resolvehanded back (absolute-shaped:/notes.txtunder fat's/volumes/usbmount). Bare names (greeting) resolve nowhere — the flat ramfs is retired, andfs_resolverefuses non-absolute paths. The returnednodeis the backend's own open-node id: with the router in the kernel there is no forwarding table, and clients hold backend ids directly (see Lifetimes and trust). Every later packet carries it inHeader.target— the path is spoken once, here, and integers do the rest. - read / write — a single exchange moves at most 224 bytes
(
maximum_payload); the client loops, advancing its own offset by what came back, until done (read) or the slice is written (write). Awritereply shorter than requested is progress, not an error; a count of 0 means no forward progress — stop rather than spin. - readdir —
cursoris the entry index, not a byte position. Each call returns exactly one entry; the client increments the cursor by 1. Aname_lenof 0 is end-of-directory — the reply's own length cannot say so, because the envelope always sends the fixed reply part. 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 verb numbers stay reserved. Mount-prefix semantics are unchanged: prefixes match at path boundaries only (/volumes/usbnever captures/volumes/usbextra), the longest matching prefix wins, and an optional backend-side rewrite prefix maps a mount into the backend's namespace (fat serves/volumes/usbfrom its volume root and/system/logsfrom its/system/logssubtree). - rename — same-directory rename only: the backend compares the old and
new parent paths and refuses a mismatch. The client (
file_system) refuses earlier when the two paths resolve to different backend endpoints, but that check is coarser than "one mount" — one endpoint can serve several mounts (fat serves/volumes/usb,/system/configurationand/system/logs), so a cross-mount rename reaches the backend and fails on its same-directory check. - bind — the protocol registry's claim verb, implemented only by the
synthetic
/protocolbackend inside PID 1 (protocol-namespace.md). A file backend answers-ENOSYS.
Open flags
Bitwise OR in open's 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's fixed part)
| 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)
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | kind |
a NodeKind value |
| 4 | 4 | name_len |
length of the name that follows; 0 means end of directory |
| 8 | 8 | size |
the entry's size in bytes |
| 16 | name_len |
name | the entry's name, not NUL-terminated |
NodeKind
Aligned to the node-kind table in the file-system hierarchy (docs/file-system-development/file-system-hierarchy.md):
| value | kind |
|---|---|
| 0 | regular file |
| 1 | directory |
| 2 | character device |
| 3 | block device |
| 4 | symbolic link |
| 5 | fifo |
| 6 | socket |
| 7 | protocol |
Clients should map unknown values to regular rather than reject — the
table can grow. Kind 6 (socket) keeps its wire value but is retired from
the design — a named rendezvous point is exactly what a protocol node is,
landed as value 7 with the protocol namespace
(docs/os-development/protocol-namespace.md). character_device (stream
semantics — the tty/console shape) and block_device (raw sector-addressed
volumes, reserved) remain part of the design.
An open reply may carry a capability
open rides ipc_call, whose reply direction can hand back an endpoint
capability alongside the reply. A file backend never uses it — FAT
answers with a node id and nothing else — but a synthetic backend does:
opening a protocol node returns the provider's endpoint, and possession of
that endpoint is the channel. The convention is per-backend, not
per-operation, so a client that opens an ordinary file simply receives no
capability, exactly as before.
Lifetimes and trust
Open-node ids live in the backend. A client that dies without closing leaks nothing permanently: the backend (the FAT server) subscribes to the kernel's published process-exit events (docs/process-lifecycle.md) and releases a dead client's handles. The kernel VFS root needs no sweep at all — its node tokens are permanent for a boot and carry no open state. Ids are plain integers, not capabilities — a backend 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 test inlibrary/protocol/vfs/vfs-protocol.zigpins a sample of them (theDirectoryEntrysize,NodeKind0–1 and 6–7,Operationvalues 16–21, 26 and 27); this page is the full record of the frozen values. The one renumbering this contract has had was the rebase onto the envelope (P4a), which moved every verb above the reserved range — a deliberate flag-day across a system with no third-party clients yet, not a precedent. - 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. - Success is exactly 0, and the negative statuses come from one system-wide errno vocabulary (the kernel's, continued by the envelope) rather than from this protocol.