danos/docs/file-system-development/vfs-protocol.md

13 KiB
Raw Blame History

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_resolve routes a path and either serves it directly (the read-only /system initrd mount, via fs_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 through envelope.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 is library/protocol/vfs/vfs-protocol.zig (the vfs-protocol module), 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 Header on a request, a Status on 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 (016 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); 015 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 015 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_resolve handed back (absolute-shaped: /notes.txt under fat's /volumes/usb mount). Bare names (greeting) resolve nowhere — the flat ramfs is retired, and fs_resolve refuses non-absolute paths. The returned node is 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 in Header.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). A write reply shorter than requested is progress, not an error; a count of 0 means no forward progress — stop rather than spin.
  • readdircursor is the entry index, not a byte position. Each call returns exactly one entry; the client increments the cursor by 1. A name_len of 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 the directory flag.
  • mount / unmount — RETIRED from the wire: mounting is the fs_mount syscall 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/usb never 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/usb from its volume root and /system/logs from its /system/logs subtree).
  • 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/configuration and /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 /protocol backend 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 rather than capabilities, so the backend scopes them to the caller's badge: an open node belongs to the task that opened it, and every verb that names one — read, write, status, readdir, close — is answered only for that task. A node id is a small number drawn from a table of thirty-two, trivially guessable, and until this rule a backend honoured every client's ids from every other client (docs/os-development/protocol-namespace.md: handles must be scoped per client — validated against the badge, or drawn from a per-client id namespace).

The refusal is deliberately identical to absence: a node that is somebody else's answers -ENOENT, exactly as one that was never opened, so a prober learns nothing about which ids are live — the same discipline the protocol namespace applies to a refused open. The owner is a task, because the badge is: a threaded client uses a node from the thread that opened it, which is already the granularity of the exit sweep that releases it.

Evolution rules

What a non-Zig implementation may rely on, and what it must not:

  • Operation values, flag bits, NodeKind values, and struct layouts are append-only and frozen once shipped. The unit test in library/protocol/vfs/vfs-protocol.zig pins a sample of them (the DirectoryEntry size, NodeKind 01 and 67, Operation values 1621, 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.