library: five protocols speak the envelope

The folded header stops being a rule in a document and becomes the layout
on the wire. Verbs number from sixteen, leaving describe, enumerate,
subscribe and unsubscribe reserved and answered the same way by every
provider — none of them writes a line to do it. What each protocol used to
carry in a field of its own now travels in the header: a vfs node and a
display layer are the packet's target, and a reply opens with a status the
envelope stamps rather than one each protocol spelled for itself.

Display gains the most. One forty-byte request had served eleven verbs, so
attach_scanout smuggled stride through x, refresh through y and format
through colour, and every coordinate crossed as a bitcast. Per-operation
structs end all three: the fields have their own names and their own signs,
and the tile payload grows to 224 bytes because the prefix shrank. Scanout
loses a message maximum of 64 it had no business declaring — it answers
calls, and the floor for a call is 256 — and virtio-gpu stops hard-coding
that number at its harness.

Two changes are semantic rather than notational. A directory now ends at an
entry with no name, because the fixed part of a reply always travels and a
zero-length reply no longer exists to mean anything. And input joins the
service harness, the last loop in the tree that answered no ping and heard
no terminate; its subscriber table, its pruning and its fan-out are the
same code, and a shutdown now asks it to stop instead of killing it.

A new conformance case reads the registry's own listing and asks every
protocol it finds for its name, its version and its verb count, then offers
a verb nobody defines and requires -ENOSYS — the envelope's promise,
checked against providers rather than against itself. What it cannot reach
in that boot it names on the serial line instead of passing quietly.

Suite 110/110.
This commit is contained in:
Daniel Samson 2026-08-01 06:15:25 +01:00
parent b004b9c3eb
commit d2dfbcabf8
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
40 changed files with 1882 additions and 1008 deletions

View File

@ -340,6 +340,7 @@ pub fn build(b: *std.Build) void {
"user-memory-test", // aims deliberately bad user pointers at the checked copy layer "user-memory-test", // aims deliberately bad user pointers at the checked copy layer
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
"protocol-denied-test", // restriction stage one: an ungranted open answers as absence "protocol-denied-test", // restriction stage one: an ungranted open answers as absence
"protocol-conformance-test", // the reserved verbs, asked of every provider the boot bound
}) |fixture| { }) |fixture| {
const package = b.lazyDependency(fixture, .{}) orelse const package = b.lazyDependency(fixture, .{}) orelse
@panic("a test fixture package is missing under test/system/services"); @panic("a test fixture package is missing under test/system/services");

View File

@ -77,6 +77,7 @@
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true }, .@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true }, .@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
.@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true }, .@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
.@"protocol-conformance-test" = .{ .path = "test/system/services/protocol-conformance-test", .lazy = true },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies. // See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{ //.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding // // When updating this field to a new URL, be sure to delete the corresponding

View File

@ -106,8 +106,10 @@ The bring-up sequence mirrors a hardware driver's — it is the
[`usb-xhci-bus` `initialise`](../../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape [`usb-xhci-bus` `initialise`](../../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
(claim → `mmio_map` → run loop) — and the request/reply service shell is the (claim → `mmio_map` → run loop) — and the request/reply service shell is the
[FAT](../../system/services/fat/fat.zig) / [input](../../system/services/input/input.zig) shape [FAT](../../system/services/fat/fat.zig) / [input](../../system/services/input/input.zig) shape
([`service.run`](../../library/kernel/service.zig) with a `protocol.zig` of ([`service.run`](../../library/kernel/service.zig) over the dispatch table its
`extern struct` messages and an `Operation` tag). protocol module generates through
[`envelope.Define`](../os-development/protocol-namespace.md) — one request and
reply type per verb, and the layer id in the packet header's `target`).
**One process, for now.** v1 is a *single* service that both owns the framebuffer and **One process, for now.** v1 is a *single* service that both owns the framebuffer and
composites — it does not split a "framebuffer driver" from a "compositor" the way input composites — it does not split a "framebuffer driver" from a "compositor" the way input

View File

@ -22,12 +22,22 @@ event:
- `JoystickEvent``axis` moves (a signed value on a `control` index) and - `JoystickEvent``axis` moves (a signed value on a `control` index) and
`button_down`/`button_up`. `button_down`/`button_up`.
All three travel in one **`InputEvent` envelope** tagged with a `DeviceKind`, so the A source publishes any of the three as one **`InputEvent`** tagged with a `DeviceKind`, so
fan-out is a single code path and a subscriber can take a mix of classes on one stream. `publish` is a single verb; decode one with `asKeyboard()` / `asMouse()` / `asJoystick()`
Decode an envelope with `asKeyboard()` / `asMouse()` / `asJoystick()` (each returns null (each returns null unless the tag matches). On the *delivery* wire the class is the
unless the tag matches). A subscriber names the classes it wants with a **`device_mask`**, packet's own operation instead — the protocol declares one event per class
and the service routes each event only to subscribers whose mask includes its class — so a ([protocol-namespace.md](../os-development/protocol-namespace.md)), so a pushed packet is
mouse-only listener never wakes for keystrokes. the 16-byte header plus the typed event and nothing carries a tag twice. The client
helpers re-tag what arrives back into an `InputEvent`, so a subscriber can still take a
mix of classes on one stream. A subscriber names the classes it wants with a
**`device_mask`**, and the service routes each event only to subscribers whose mask
includes its class — so a mouse-only listener never wakes for keystrokes.
**`subscribe` is not this protocol's verb.** Its shape — a synchronous call whose attached
capability is the subscriber's own endpoint — is what the envelope's *reserved* subscribe
means at every provider in the system, so the input protocol adopts it rather than
defining a second spelling of the same thing. The interest mask rides as the packet's
tail. `publish` is the one verb the protocol defines for itself.
## Why this needed a new kernel primitive ## Why this needed a new kernel primitive
@ -103,7 +113,10 @@ This is the async counterpart of `ipc_call`, and the input service is its first
event to every subscriber whose mask includes the event's device class. On `subscribe` it event to every subscriber whose mask includes the event's device class. On `subscribe` it
stores the passed capability and mask and, as housekeeping, prunes any slot whose owning stores the passed capability and mask and, as housekeeping, prunes any slot whose owning
process has exited (checked against `process_enumerate`) — not for correctness (an async process has exited (checked against `process_enumerate`) — not for correctness (an async
send to an orphaned endpoint is harmless) but to reclaim the slot. send to an orphaned endpoint is harmless) but to reclaim the slot. The service runs on the
shared harness ([service.zig](../../library/kernel/service.zig)) like every other, so it
answers the universal ping and exits on `terminate`; it was the last hand-rolled receive
loop in the tree, and the last service a shutdown had to kill rather than ask.
Publisher and subscriber must be **separate processes**: a single thread that both Publisher and subscriber must be **separate processes**: a single thread that both
published and serviced its own subscription would deadlock (its `publish` call blocks until published and serviced its own subscription would deadlock (its `publish` call blocks until

View File

@ -6,7 +6,10 @@
> serves it directly (the read-only /system initrd mount, via `fs_node`) or > 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 > redirects the caller to the owning backend's endpoint plus the rewritten
> mount-relative path — after which the client speaks THIS protocol to the > mount-relative path — after which the client speaks THIS protocol to the
> backend, unchanged. The Zig source of truth is `library/protocol/vfs/vfs-protocol.zig` > 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 > (the `vfs-protocol` module), whose unit test pins a sample of the sizes
> and values below. This page is the **language-neutral wire specification** > and values below. This page is the **language-neutral wire specification**
> of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md) > of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md)
@ -22,12 +25,17 @@ also hands back the path rewritten relative to the mount — not from a
registry lookup. (Service id 1, the old userspace router, is retired.) registry lookup. (Service id 1, the old userspace router, is retired.)
- A message is at most **256 bytes** (`message_maximum`). - A message is at most **256 bytes** (`message_maximum`).
- A request is a fixed 32-byte **Request** header followed by an inline - Every packet begins with the 16-byte **envelope prefix**
payload of at most **224 bytes** (`maximum_payload`) — a path, or write ([protocol-namespace.md](../os-development/protocol-namespace.md)): a
bytes. There is no multi-message request: paths and single reads/writes `Header` on a request, a `Status` on a reply. The prefix is **folded, not
must fit, and larger transfers loop (see *read* / *write*). stacked** — the verb and the object being addressed live in it, and no
- A reply is a fixed 24-byte **Reply** header followed by an inline payload — request or reply below repeats either.
read bytes, a `FileStatus`, or a `DirectoryEntry`. - 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 - All integers are **little-endian**; layouts are C layout for x86-64
(`extern struct`), offsets given below so nothing need be inferred. (`extern struct`), offsets given below so nothing need be inferred.
@ -37,91 +45,108 @@ 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 handle's owner and sweeps a dead client's handles via the published process
exit events. exit events.
## Request header — 32 bytes ## Request header — 16 bytes
The envelope's `Header`, identical in every danos protocol:
| offset | size | field | meaning | | offset | size | field | meaning |
|-------:|-----:|-------|---------| |-------:|-----:|-------|---------|
| 0 | 4 | `operation` | an **Operation** value (below) | | 0 | 4 | `operation` | an **Operation** value (below); 015 are the reserved universal verbs |
| 4 | 4 | — | padding | | 4 | 4 | — | padding |
| 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations | | 8 | 8 | `target` | **the open-node id** from a prior `open`; 0 for `open` itself and the path-based verbs |
| 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 ## Reply header — 16 bytes
The envelope's `Status`:
| offset | size | field | meaning | | offset | size | field | meaning |
|-------:|-----:|-------|---------| |-------:|-----:|-------|---------|
| 0 | 4 | `status` | **0 = success**, negative = failure (signed) | | 0 | 4 | `status` | **0 = success**, negative = failure (signed) |
| 4 | 4 | — | padding | | 4 | 4 | — | padding |
| 8 | 8 | `node` | the new open-node id (for `open`); else 0 | | 8 | 4 | `len` | reply bytes following this header: the verb's fixed part plus its tail |
| 16 | 4 | `len` | reply payload length in bytes | | 12 | 4 | — | padding |
| 20 | 4 | — | padding |
On failure the backend replies `status = -1`, and that reply reaches the A failing backend replies with the status alone (`len` = 0) and no fixed
client directly — there is no party between them on the wire. (Kernel-served part, and that reply reaches the client directly — there is no party between
paths produce no wire replies at all: `fs_resolve`/`fs_node` failures are them on the wire. (Kernel-served paths produce no wire replies at all:
syscall register statuses.) A richer errno vocabulary is future work — `fs_resolve`/`fs_node` failures are syscall register statuses.) The errno
clients must treat *any* negative status as failure, not match on -1. 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 ## Operations
Values are append-only and never renumbered (the same evolution rule every Values number from 16 (`first_protocol_operation`) in declaration order, and
danos protocol follows). Send only values from this table: the shipped server are frozen once shipped. Values 015 are the envelope's reserved universal
decodes the operation into an exhaustive enum, so an out-of-range value is verbs, which mean the same thing at every provider in the system: `describe`
not answered with a `status = -1` reply — it trips a safety check in safe (0) answers the protocol's name and version and is implemented by the
builds and is undefined otherwise. (The `-1` replies cover recognised but envelope itself, so every backend answers it. A verb outside this table is
refused operations, such as `mount` sent to a backend.) answered `-ENOSYS`; it is never a safety check any more, because the
dispatch compares numbers rather than decoding an enum.
| value | operation | request payload | reply | Each row's *request* and *reply* name the bytes **after** the 16-byte prefix.
|------:|-----------|-----------------|-------|
| 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id | | value | operation | request | tail | reply | reply tail |
| 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 | | 16 | `open` | `flags` (4 bytes, below) | the path | `node` (8 bytes) = the open-node id | — |
| 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) | | 17 | `close` | — | — | — | — |
| 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) | | 18 | `read` | `offset` (8), `len` (4) = wanted count | — | — | the bytes read; `Status.len` 0 at end of file |
| 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end | | 19 | `write` | `offset` (8), `len` (4) = count | the bytes | `count` (4) = bytes accepted (may be short — loop) | — |
| 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only | | 20 | `status` | — | — | **FileStatus** (24 bytes) | — |
| 7 | `unmount` | the mount-point path | status only | | 21 | `readdir` | `cursor` (8) | — | one **DirectoryEntry** (16 bytes) | the name |
| 8 | `mkdir` | the path | status only | | 22 | `mount` | — | the mount-point path; the backend endpoint rides as the call's **capability** | — | — |
| 9 | `unlink` | the path | status only | | 23 | `unmount` | — | the mount-point path | — | — |
| 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only | | 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: Notes per operation:
- **open** — the path is the mount-relative path `fs_resolve` handed back - **open** — the path is the mount-relative path `fs_resolve` handed back
(absolute-shaped: `/notes.txt` under fat's `/mnt/usb` mount). Bare names (absolute-shaped: `/notes.txt` under fat's `/volumes/usb` mount). Bare names
(`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve` (`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve`
refuses non-absolute paths. The returned `node` is the *backend's* own 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, open-node id: with the router in the kernel there is no forwarding table,
and clients hold backend ids directly (see *Lifetimes and trust*). 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 - **read / write** — a single exchange moves at most 224 bytes
(`maximum_payload`); the client loops, advancing `offset` by the returned (`maximum_payload`); the client loops, advancing its own offset by what
`len`, until done (read) or the slice is written (write). A `write` reply came back, until done (read) or the slice is written (write). A `write`
shorter than requested is progress, not an error; a `len` of 0 means no reply shorter than requested is progress, not an error; a count of 0 means
forward progress — stop rather than spin. no forward progress — stop rather than spin.
- **readdir**`offset` is a **cursor: the entry index**, not a byte - **readdir**`cursor` is the **entry index**, not a byte position. Each
position. Each call returns exactly one entry; the client increments the call returns exactly one entry; the client increments the cursor by 1. **A
cursor by 1. A reply with `len` 0 is end-of-directory. The directory must `name_len` of 0 is end-of-directory** — the reply's own length cannot say
have been opened with the `directory` flag. 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` - **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount`
syscall now (a filesystem server passes its endpoint handle; possession is syscall now (a filesystem server passes its endpoint handle; possession is
the capability, exactly the trust of the old cap-passing op). The op the capability, exactly the trust of the old cap-passing op). The verb
numbers stay reserved. Mount-prefix semantics are unchanged: prefixes numbers stay reserved. Mount-prefix semantics are unchanged: prefixes
match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`), match at path boundaries only (`/volumes/usb` never captures
the longest matching prefix wins, and an optional backend-side rewrite `/volumes/usbextra`), the longest matching prefix wins, and an optional
prefix maps a mount into the backend's namespace (fat serves `/mnt/usb` backend-side rewrite prefix maps a mount into the backend's namespace (fat
from its volume root and `/var` from its `/var` subtree). 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 - **rename** — same-directory rename only: the backend compares the old and
new parent paths and refuses a mismatch. The client (`file_system`) refuses new parent paths and refuses a mismatch. The client (`file_system`) refuses
earlier when the two paths resolve to different backend endpoints, but that earlier when the two paths resolve to different backend endpoints, but that
check is coarser than "one mount" — one endpoint can serve several mounts check is coarser than "one mount" — one endpoint can serve several mounts
(fat serves `/mnt/usb` and `/var`), so a cross-mount rename reaches the (fat serves `/volumes/usb`, `/system/configuration` and `/system/logs`), so
backend and fails on its same-directory check. 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](../os-development/protocol-namespace.md)). A file
backend answers `-ENOSYS`.
## Open flags ## Open flags
Bitwise OR in `Request.flags`, meaningful for `open` only: Bitwise OR in `open`'s `flags`, meaningful for `open` only:
| bit | name | meaning | | bit | name | meaning |
|----:|------|---------| |----:|------|---------|
@ -129,7 +154,7 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
| 2 | `directory` | open a directory node for `readdir` rather than a file | | 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) | | 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) |
## FileStatus — 24 bytes (the `status` reply payload) ## FileStatus — 24 bytes (the `status` reply's fixed part)
| offset | size | field | meaning | | offset | size | field | meaning |
|-------:|-----:|-------|---------| |-------:|-----:|-------|---------|
@ -138,12 +163,12 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
| 12 | 4 | — | padding | | 12 | 4 | — | padding |
| 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none | | 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none |
## DirectoryEntry — 16 bytes + name (the `readdir` reply payload) ## DirectoryEntry — 16 bytes + name (the `readdir` reply)
| offset | size | field | meaning | | offset | size | field | meaning |
|-------:|-----:|-------|---------| |-------:|-----:|-------|---------|
| 0 | 4 | `kind` | a **NodeKind** value | | 0 | 4 | `kind` | a **NodeKind** value |
| 4 | 4 | `name_len` | length of the name that follows | | 4 | 4 | `name_len` | length of the name that follows; **0 means end of directory** |
| 8 | 8 | `size` | the entry's size in bytes | | 8 | 8 | `size` | the entry's size in bytes |
| 16 | `name_len` | name | the entry's name, not NUL-terminated | | 16 | `name_len` | name | the entry's name, not NUL-terminated |
@ -174,7 +199,7 @@ volumes, reserved) remain part of the design.
## An open reply may carry a capability ## An open reply may carry a capability
`open` rides `ipc_call`, whose reply direction can hand back an endpoint `open` rides `ipc_call`, whose reply direction can hand back an endpoint
capability alongside the `Reply` header. A file backend never uses it — FAT capability alongside the reply. A file backend never uses it — FAT
answers with a node id and nothing else — but a **synthetic** backend does: 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 opening a `protocol` node returns the provider's endpoint, and possession of
that endpoint *is* the channel. The convention is per-backend, not that endpoint *is* the channel. The convention is per-backend, not
@ -198,11 +223,15 @@ What a non-Zig implementation may rely on, and what it must not:
- Operation values, flag bits, `NodeKind` values, and struct layouts are - Operation values, flag bits, `NodeKind` values, and struct layouts are
**append-only and frozen once shipped**. The unit test in **append-only and frozen once shipped**. The unit test in
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry` `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the
size, `NodeKind` 01 and 67, `Operation` values 0, 4 and 5); this page is `DirectoryEntry` size, `NodeKind` 01 and 67, `Operation` values 1621, 26
the full record of the frozen values. 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, - 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 not a promise; clients should read `maximum_payload`-shaped limits from the
reply lengths they actually get (loop-until-done), not hard-code 224. reply lengths they actually get (loop-until-done), not hard-code 224.
- Negative statuses beyond -1 will appear (an errno vocabulary); success is - Success is exactly 0, and the negative statuses come from one system-wide
exactly 0. errno vocabulary (the kernel's, continued by the envelope) rather than from
this protocol.

View File

@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.**
| | | | | |
|---|---| |---|---|
| Working on | **P4a** — protocol rebase onto `envelope.Define` | | Working on | **P4b** — device-manager, power, usb-transfer onto `Define` |
| Branch carrying it | `feat/security-group-3` (cut next) | | Branch carrying it | `feat/security-group-3` (pushed to origin) |
| On `main` | Phase 0, PM, H1, P1, P2, P3 — groups 1 and 2 merged | | On `main` | Phase 0, PM, H1, P1, P2, P3 — groups 1 and 2 merged |
| Awaiting merge | nothing | | Awaiting merge | P4a — lands with the group 3 merge |
| Suite | 109 cases, all passing | | Suite | 110 cases, all passing |
| Last updated | 2026-08-01 | | Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was A checkbox below means the phase met its definition of green and was
@ -65,7 +65,7 @@ group boundary.
supervising task per contract and is deliberately **open-only**, leaving P2's supervising task per contract and is deliberately **open-only**, leaving P2's
bind attestation and every refusal it makes untouched (suite 109/109) bind attestation and every refusal it makes untouched (suite 109/109)
- [x] **merge** group 2 → main, push - [x] **merge** group 2 → main, push
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) - [x] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input; display's one overloaded request split per-operation and its field abuse ended, scanout's bogus 64-byte maximum deleted, directory EOF re-spelled as a nameless entry, input moved onto the service harness; new `protocol-conformance` case asks every reachable provider for `describe` and requires `-ENOSYS` for an undefined verb; suite 110/110)
- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer) - [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer)
- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers - [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers
- [ ] **merge** group 3 → main, push - [ ] **merge** group 3 → main, push

View File

@ -17,10 +17,15 @@ pub fn build(b: *std.Build) void {
// (docs/os-development/protocol-namespace.md). // (docs/os-development/protocol-namespace.md).
const channel = kernel.module("channel"); const channel = kernel.module("channel");
// A client frames its own packets, so it needs the envelope alongside the
// protocol whose verbs it speaks.
const envelope = protocol.module("envelope");
_ = b.addModule("display-client", .{ _ = b.addModule("display-client", .{
.root_source_file = b.path("display/display-client.zig"), .root_source_file = b.path("display/display-client.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel }, .{ .name = "channel", .module = channel },
.{ .name = "envelope", .module = envelope },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "display-protocol", .module = protocol.module("display-protocol") }, .{ .name = "display-protocol", .module = protocol.module("display-protocol") },
@ -30,6 +35,7 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("input/input-client.zig"), .root_source_file = b.path("input/input-client.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel }, .{ .name = "channel", .module = channel },
.{ .name = "envelope", .module = envelope },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "input-protocol", .module = protocol.module("input-protocol") }, .{ .name = "input-protocol", .module = protocol.module("input-protocol") },

View File

@ -5,10 +5,13 @@
const std = @import("std"); const std = @import("std");
const channel = @import("channel"); const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const display_protocol = @import("display-protocol"); const display_protocol = @import("display-protocol");
const Protocol = display_protocol.Protocol;
/// The display's current mode, as `info()` reports it. /// The display's current mode, as `info()` reports it.
pub const Info = struct { pub const Info = struct {
width: u32, width: u32,
@ -35,29 +38,45 @@ fn service() ?ipc.Handle {
return null; return null;
} }
/// Send one request, receive its reply; true on a zero status. `out` receives the reply /// A reply the compositor answered with, kept whole so the caller can decode the
/// so callers can read `info`/`layer` fields on success. /// verb's own fixed part out of it.
fn transact(request: display_protocol.Request, out: *display_protocol.Reply) bool { const Answered = struct {
const h = service() orelse return false; packet: [display_protocol.message_maximum]u8,
var req = request; len: usize,
var reply: [display_protocol.reply_size]u8 = undefined;
const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false; fn bytes(self: *const Answered) []const u8 {
if (len < display_protocol.reply_size) return false; return self.packet[0..self.len];
out.* = std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]); }
return out.status == 0; };
/// Send one request (`target` addresses a layer, or 0 for the compositor itself)
/// and keep the reply. Null when the transport failed or the compositor refused.
fn transact(
comptime operation: Protocol.Operation,
target: u64,
request: Protocol.RequestOf(operation),
tail: []const u8,
) ?Answered {
const h = service() orelse return null;
var packet: [display_protocol.message_maximum]u8 = undefined;
const framed = Protocol.encodeRequest(operation, target, request, tail, &packet) orelse return null;
var answered: Answered = .{ .packet = undefined, .len = 0 };
answered.len = ipc.call(h, framed, &answered.packet) catch return null;
const status = envelope.statusOf(answered.bytes()) orelse return null;
if (status.status != 0) return null;
return answered;
} }
/// The display's current mode, or null if the service never came up. /// The display's current mode, or null if the service never came up.
pub fn info() ?Info { pub fn info() ?Info {
var reply: display_protocol.Reply = undefined; const answered = transact(.info, 0, {}, &.{}) orelse return null;
if (!transact(.{ .operation = @intFromEnum(display_protocol.Operation.info) }, &reply)) return null; const reply = Protocol.decodeReply(.info, answered.bytes()) orelse return null;
return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format }; return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format };
} }
/// Composite the dirty layers and flush the frame to the screen. /// Composite the dirty layers and flush the frame to the screen.
pub fn present() bool { pub fn present() bool {
var reply: display_protocol.Reply = undefined; return transact(.present, 0, {}, &.{}) != null;
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.present) }, &reply);
} }
/// One selectable display mode. /// One selectable display mode.
@ -66,23 +85,17 @@ pub const Mode = display_protocol.Mode;
/// Fill `out` with the resolutions the display can switch to; returns how many were written /// Fill `out` with the resolutions the display can switch to; returns how many were written
/// (zero on the GOP floor, or if the service never came up). /// (zero on the GOP floor, or if the service never came up).
pub fn modes(out: []Mode) usize { pub fn modes(out: []Mode) usize {
const h = service() orelse return 0; const answered = transact(.get_modes, 0, {}, &.{}) orelse return 0;
var request = display_protocol.Request{ .operation = @intFromEnum(display_protocol.Operation.get_modes) }; const offered = Protocol.decodeReply(.get_modes, answered.bytes()) orelse return 0;
var reply: [display_protocol.modes_reply_size]u8 = undefined; const count = @min(@min(offered.count, display_protocol.max_modes), out.len);
const len = ipc.call(h, std.mem.asBytes(&request), &reply) catch return 0; for (0..count) |i| out[i] = offered.modes[i];
if (len < display_protocol.modes_reply_size) return 0;
const answer = std.mem.bytesToValue(display_protocol.ModesReply, reply[0..display_protocol.modes_reply_size]);
if (answer.status != 0) return 0;
const count = @min(@min(answer.count, display_protocol.max_modes), out.len);
for (0..count) |i| out[i] = answer.modes[i];
return count; return count;
} }
/// Change the display resolution. Only a native backend that supports mode-setting honours it /// Change the display resolution. Only a native backend that supports mode-setting honours it
/// (on the GOP floor it returns false); on success the display's `info()` reports the new mode. /// (on the GOP floor it returns false); on success the display's `info()` reports the new mode.
pub fn setMode(width: u32, height: u32) bool { pub fn setMode(width: u32, height: u32) bool {
var reply: display_protocol.Reply = undefined; const changed = transact(.set_mode, 0, .{ .width = width, .height = height }, &.{}) != null;
const changed = transact(.{ .operation = @intFromEnum(display_protocol.Operation.set_mode), .width = width, .height = height }, &reply);
if (changed) mode = null; // the cached mode is stale now if (changed) mode = null; // the cached mode is stale now
return changed; return changed;
} }
@ -107,93 +120,46 @@ pub fn color(r: u8, g: u8, b: u8) u32 {
/// A handle to a server-owned layer: a positioned, z-ordered surface the client draws /// A handle to a server-owned layer: a positioned, z-ordered surface the client draws
/// into by command. Create with `createLayer`; drawing and moves take effect on the next /// into by command. Create with `createLayer`; drawing and moves take effect on the next
/// `present`. Coordinates are signed (a layer may sit partly off-screen). /// `present`. Coordinates are signed (a layer may sit partly off-screen).
///
/// The id is the packet header's `target` on every call below, so it is named once
/// per request rather than repeated inside one.
pub const Layer = struct { pub const Layer = struct {
id: u32, id: u32,
/// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`. /// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`.
pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool { pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool {
var reply: display_protocol.Reply = undefined; return transact(.fill_rect, self.id, .{ .x = x, .y = y, .width = w, .height = h, .colour = colour }, &.{}) != null;
return transact(.{
.operation = @intFromEnum(display_protocol.Operation.fill_rect),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.colour = colour,
}, &reply);
} }
/// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this /// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this
/// layer at (`x`, `y`). The tile rides inline in the request, so `w*h*4` must fit /// layer at (`x`, `y`). The tile rides inline as the request's tail, so `w*h*4` must
/// `display_protocol.maximum_payload`. /// fit `display_protocol.maximum_payload` the bound the protocol derives from this
/// verb's own fixed part, so the check here can never drift from what fits.
pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool { pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
var request = display_protocol.Request{ if (pixels.len > display_protocol.maximum_payload) return false;
.operation = @intFromEnum(display_protocol.Operation.blit_tile), return transact(.blit_tile, self.id, .{ .x = x, .y = y, .width = w, .height = h }, pixels) != null;
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
};
const header = std.mem.asBytes(&request);
if (header.len + pixels.len > display_protocol.message_maximum) return false;
var buffer: [display_protocol.message_maximum]u8 = undefined;
@memcpy(buffer[0..header.len], header);
@memcpy(buffer[header.len..][0..pixels.len], pixels);
const h_svc = service() orelse return false;
var reply: [display_protocol.reply_size]u8 = undefined;
const len = ipc.call(h_svc, buffer[0 .. header.len + pixels.len], &reply) catch return false;
if (len < display_protocol.reply_size) return false;
return std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]).status == 0;
} }
/// Move / restack / show or hide the layer. /// Move / restack / show or hide the layer.
pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool { pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool {
var reply: display_protocol.Reply = undefined; return transact(.configure_layer, self.id, .{ .x = x, .y = y, .z = z, .visible = if (visible) 1 else 0 }, &.{}) != null;
return transact(.{
.operation = @intFromEnum(display_protocol.Operation.configure_layer),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.z = z,
.visible = if (visible) 1 else 0,
}, &reply);
} }
/// Mark a rectangle of this layer (layer-local) dirty for the next present for when /// Mark a rectangle of this layer (layer-local) dirty for the next present for when
/// the layer's pixels changed without a drawing call the compositor already tracked. /// the layer's pixels changed without a drawing call the compositor already tracked.
pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool { pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool {
var reply: display_protocol.Reply = undefined; return transact(.damage, self.id, .{ .x = x, .y = y, .width = w, .height = h }, &.{}) != null;
return transact(.{
.operation = @intFromEnum(display_protocol.Operation.damage),
.layer = self.id,
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
}, &reply);
} }
/// Release the layer and its surface. /// Release the layer and its surface.
pub fn destroy(self: Layer) bool { pub fn destroy(self: Layer) bool {
var reply: display_protocol.Reply = undefined; return transact(.destroy_layer, self.id, {}, &.{}) != null;
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.destroy_layer), .layer = self.id }, &reply);
} }
}; };
/// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order /// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order
/// `z` (higher is nearer the front), initially visible. Returns a handle, or null. /// `z` (higher is nearer the front), initially visible. Returns a handle, or null.
pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer { pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer {
var reply: display_protocol.Reply = undefined; const answered = transact(.create_layer, 0, .{ .x = x, .y = y, .width = w, .height = h, .z = z, .visible = 1 }, &.{}) orelse return null;
if (!transact(.{ return .{ .id = (Protocol.decodeReply(.create_layer, answered.bytes()) orelse return null).layer };
.operation = @intFromEnum(display_protocol.Operation.create_layer),
.x = @bitCast(x),
.y = @bitCast(y),
.width = w,
.height = h,
.z = z,
.visible = 1,
}, &reply)) return null;
return .{ .id = reply.layer };
} }

View File

@ -21,12 +21,14 @@
//! if (event.asKeyboard()) |k| { ... } else if (event.asMouse()) |m| { ... } //! if (event.asKeyboard()) |k| { ... } else if (event.asMouse()) |m| { ... }
//! } //! }
const std = @import("std");
const channel = @import("channel"); const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const input_protocol = @import("input-protocol"); const input_protocol = @import("input-protocol");
const Protocol = input_protocol.Protocol;
pub const DeviceKind = input_protocol.DeviceKind; pub const DeviceKind = input_protocol.DeviceKind;
pub const InputEvent = input_protocol.InputEvent; pub const InputEvent = input_protocol.InputEvent;
pub const KeyEvent = input_protocol.KeyEvent; pub const KeyEvent = input_protocol.KeyEvent;
@ -66,31 +68,44 @@ pub const Subscriber = struct {
/// The endpoint the service delivers events to (created and owned by us; its handle /// The endpoint the service delivers events to (created and owned by us; its handle
/// was handed to the service as a capability at subscribe time). /// was handed to the service as a capability at subscribe time).
endpoint: ipc.Handle, endpoint: ipc.Handle,
receive: [input_protocol.event_size]u8 = undefined, /// A pushed packet is the folded header plus one typed event, so the buffer is
/// the push floor rather than any one event's size.
receive: [envelope.post_maximum]u8 = undefined,
/// Block until the next event is pushed, and return it. Events arrive as asynchronous /// Block until the next event is pushed, and return it. Events arrive as asynchronous
/// buffered messages (`ipc_send` from the service), so nothing is owed in reply the /// buffered messages (`ipc_send` from the service), so nothing is owed in reply the
/// empty reply this issues is a harmless no-op. Returns null for any non-event wake-up /// empty reply this issues is a harmless no-op. Returns null for any non-event wake-up
/// (there should be none), so callers can loop. /// (there should be none), so callers can loop.
///
/// The device class is the packet's operation, so it is read from the header and
/// re-tagged into an `InputEvent` here one decoded type for a caller that took
/// several classes on one stream.
pub fn next(self: *Subscriber) ?InputEvent { pub fn next(self: *Subscriber) ?InputEvent {
const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null); const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null);
if (!got.isMessage() or got.len < input_protocol.event_size) return null; if (!got.isMessage()) return null;
return std.mem.bytesToValue(InputEvent, self.receive[0..input_protocol.event_size]); const packet = self.receive[0..@min(got.len, self.receive.len)];
return switch (Protocol.eventOf(packet) orelse return null) {
.keyboard => InputEvent.fromKeyboard(Protocol.decodeEvent(.keyboard, packet) orelse return null),
.mouse => InputEvent.fromMouse(Protocol.decodeEvent(.mouse, packet) orelse return null),
.joystick => InputEvent.fromJoystick(Protocol.decodeEvent(.joystick, packet) orelse return null),
};
} }
}; };
/// Subscribe to the input classes named in `device_mask` (an OR of `device_*`, or /// Subscribe to the input classes named in `device_mask` (an OR of `device_*`, or
/// `device_all`). Creates an endpoint for the service to push to and hands it over as a /// `device_all`). Creates an endpoint for the service to push to and hands it over as a
/// capability. Returns a `Subscriber` to loop `next` on, or null on failure. /// capability the envelope's reserved `subscribe`, whose shape this is exactly. Returns
/// a `Subscriber` to loop `next` on, or null on failure.
pub fn subscribe(device_mask: u32) ?Subscriber { pub fn subscribe(device_mask: u32) ?Subscriber {
const service = lookupService() orelse return null; const service = lookupService() orelse return null;
const endpoint = ipc.createIpcEndpoint() orelse return null; const endpoint = ipc.createIpcEndpoint() orelse return null;
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.subscribe), .device_mask = device_mask }; var packet: [input_protocol.message_maximum]u8 = undefined;
var reply: [input_protocol.reply_size]u8 = undefined; const framed = input_protocol.encodeSubscribe(device_mask, &packet) orelse return null;
const result = ipc.callCap(service, std.mem.asBytes(&request), &reply, endpoint) catch return null; var reply: [input_protocol.message_maximum]u8 = undefined;
if (result.len < input_protocol.reply_size) return null; const result = ipc.callCap(service, framed, &reply, endpoint) catch return null;
if (std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status != 0) return null; const status = envelope.statusOf(reply[0..result.len]) orelse return null;
if (status.status != 0) return null;
return .{ .endpoint = endpoint }; return .{ .endpoint = endpoint };
} }
@ -149,11 +164,12 @@ pub const Publisher = struct {
service: ipc.Handle, service: ipc.Handle,
fn publish(self: Publisher, event: InputEvent) bool { fn publish(self: Publisher, event: InputEvent) bool {
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.publish), .event = event }; var packet: [input_protocol.message_maximum]u8 = undefined;
var reply: [input_protocol.reply_size]u8 = undefined; const framed = Protocol.encodeRequest(.publish, 0, event, &.{}, &packet) orelse return false;
const len = ipc.call(self.service, std.mem.asBytes(&request), &reply) catch return false; var reply: [input_protocol.message_maximum]u8 = undefined;
if (len < input_protocol.reply_size) return false; const len = ipc.call(self.service, framed, &reply) catch return false;
return std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status == 0; const status = envelope.statusOf(reply[0..len]) orelse return false;
return status.status == 0;
} }
/// Broadcast a keyboard event to every subscriber that took keyboard events. /// Broadcast a keyboard event to every subscriber that took keyboard events.

View File

@ -7,12 +7,14 @@
//! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size //! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size
//! limit the same handoff usb-storage uses toward the controller. //! limit the same handoff usb-storage uses toward the controller.
const std = @import("std");
const channel = @import("channel"); const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const block_protocol = @import("block-protocol"); const block_protocol = @import("block-protocol");
const Protocol = block_protocol.Protocol;
pub const Geometry = struct { block_size: u32, block_count: u64 }; pub const Geometry = struct { block_size: u32, block_count: u64 };
pub const Device = struct { pub const Device = struct {
@ -20,12 +22,9 @@ pub const Device = struct {
/// The device's block size and total block count. /// The device's block size and total block count.
pub fn geometry(self: Device) ?Geometry { pub fn geometry(self: Device) ?Geometry {
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.geometry), .lba = 0, .count = 0, .physical = 0 }; var reply: [block_protocol.message_maximum]u8 = undefined;
var reply: [block_protocol.reply_size]u8 = undefined; const answered = self.call(.geometry, {}, null, &reply) orelse return null;
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return null; const result = Protocol.decodeReply(.geometry, answered) orelse return null;
if (n < block_protocol.reply_size) return null;
const result = std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]);
if (result.status != 0) return null;
return .{ .block_size = result.block_size, .block_count = result.block_count }; return .{ .block_size = result.block_size, .block_count = result.block_count };
} }
@ -34,36 +33,45 @@ pub const Device = struct {
/// addresses become reachable by the device. Call once per buffer before naming it /// addresses become reachable by the device. Call once per buffer before naming it
/// in `read`/`write`. Harmless success when no IOMMU is enforcing. /// in `read`/`write`. Harmless success when no IOMMU is enforcing.
pub fn attach(self: Device, handle: ipc.Handle) bool { pub fn attach(self: Device, handle: ipc.Handle) bool {
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.attach), .lba = 0, .count = 0, .physical = 0 }; var reply: [block_protocol.message_maximum]u8 = undefined;
var reply: [block_protocol.reply_size]u8 = undefined; return self.call(.attach, {}, handle, &reply) != null;
const result = ipc.callCap(self.endpoint, std.mem.asBytes(&request), &reply, handle) catch return false;
if (result.len < block_protocol.reply_size) return false;
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
} }
/// Read `count` blocks starting at `lba` into the DMA buffer at `physical`. /// Read `count` blocks starting at `lba` into the DMA buffer at `physical`.
pub fn read(self: Device, lba: u64, count: u32, physical: u64) bool { pub fn read(self: Device, lba: u64, count: u32, physical: u64) bool {
return self.transfer(.read, lba, count, physical); var reply: [block_protocol.message_maximum]u8 = undefined;
return self.call(.read, .{ .lba = lba, .count = count, .physical = physical }, null, &reply) != null;
} }
/// Write `count` blocks starting at `lba` from the DMA buffer at `physical`. /// Write `count` blocks starting at `lba` from the DMA buffer at `physical`.
pub fn write(self: Device, lba: u64, count: u32, physical: u64) bool { pub fn write(self: Device, lba: u64, count: u32, physical: u64) bool {
return self.transfer(.write, lba, count, physical); var reply: [block_protocol.message_maximum]u8 = undefined;
return self.call(.write, .{ .lba = lba, .count = count, .physical = physical }, null, &reply) != null;
} }
/// Commit any device write cache to stable media (SCSI SYNCHRONIZE CACHE), so /// Commit any device write cache to stable media (SCSI SYNCHRONIZE CACHE), so
/// prior writes survive a power-off. A filesystem calls this before the machine /// prior writes survive a power-off. A filesystem calls this before the machine
/// goes down; no data transfer, so the buffer arguments are unused. /// goes down; no data transfer, so the buffer arguments are unused.
pub fn flush(self: Device) bool { pub fn flush(self: Device) bool {
return self.transfer(.flush, 0, 0, 0); var reply: [block_protocol.message_maximum]u8 = undefined;
return self.call(.flush, {}, null, &reply) != null;
} }
fn transfer(self: Device, operation: block_protocol.Operation, lba: u64, count: u32, physical: u64) bool { /// One request at the driver. `target` is always 0: one endpoint per device, so
var request = block_protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical }; /// there is no object within the peer to address.
var reply: [block_protocol.reply_size]u8 = undefined; fn call(
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return false; self: Device,
if (n < block_protocol.reply_size) return false; comptime operation: Protocol.Operation,
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0; request: Protocol.RequestOf(operation),
capability: ?ipc.Handle,
reply: []u8,
) ?[]u8 {
var packet: [block_protocol.message_maximum]u8 = undefined;
const framed = Protocol.encodeRequest(operation, 0, request, &.{}, &packet) orelse return null;
const answer = ipc.callCap(self.endpoint, framed, reply, capability) catch return null;
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
if (status.status != 0) return null;
return reply[0..answer.len];
} }
}; };

View File

@ -99,6 +99,7 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("block/block.zig"), .root_source_file = b.path("block/block.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel }, .{ .name = "channel", .module = channel },
.{ .name = "envelope", .module = protocol.module("envelope") },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "block-protocol", .module = protocol.module("block-protocol") }, .{ .name = "block-protocol", .module = protocol.module("block-protocol") },

View File

@ -59,6 +59,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "system-call", .module = system_call }, .{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
.{ .name = "envelope", .module = protocol.module("envelope") },
}, },
}); });
// The channel is the L1 concept made concrete (docs/os-development/communication.md): // The channel is the L1 concept made concrete (docs/os-development/communication.md):

View File

@ -192,41 +192,37 @@ fn reach(path: []const u8) ?Registry {
} }
} }
/// One vfs-protocol round trip at a backend: fixed header, inline payload, and /// One vfs-protocol round trip at a backend: the folded header, the verb's own
/// an optional capability in each direction. /// fixed part, the name as the packet's tail, and an optional capability in each
/// direction. Both verbs this file sends address the backend itself (target 0)
/// the name in the tail is what they are about.
fn transact( fn transact(
comptime operation: vfs_protocol.Operation,
handle: ipc.Handle, handle: ipc.Handle,
operation: vfs_protocol.Operation, request: vfs_protocol.Protocol.RequestOf(operation),
payload: []const u8, name: []const u8,
send_capability: ?ipc.Handle, send_capability: ?ipc.Handle,
) ?struct { reply: vfs_protocol.Reply, capability: ?ipc.Handle } { ) ?struct { status: envelope.Status, capability: ?ipc.Handle } {
var request: [vfs_protocol.message_maximum]u8 = undefined; var packet: [vfs_protocol.message_maximum]u8 = undefined;
if (vfs_protocol.request_size + payload.len > request.len) return null; const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null;
const header = vfs_protocol.Request{
.operation = operation,
.node = 0,
.offset = 0,
.len = @intCast(payload.len),
.flags = 0,
};
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
@memcpy(request[vfs_protocol.request_size..][0..payload.len], payload);
var reply: [vfs_protocol.message_maximum]u8 = undefined; var reply: [vfs_protocol.message_maximum]u8 = undefined;
const answer = ipc.callCap(handle, request[0 .. vfs_protocol.request_size + payload.len], &reply, send_capability) catch return null; const answer = ipc.callCap(handle, framed, &reply, send_capability) catch return null;
if (answer.len < vfs_protocol.reply_size) return null; const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
return .{ return .{ .status = status, .capability = answer.cap };
.reply = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]),
.capability = answer.cap,
};
} }
/// Resolve an absolute `/protocol/...` path and take the provider's endpoint out /// Resolve an absolute `/protocol/...` path and take the provider's endpoint out
/// of the open reply's capability. /// of the open reply's capability.
fn openPath(path: []const u8) ?ipc.Handle { fn openPath(path: []const u8) ?ipc.Handle {
const registry = reach(path) orelse return null; const registry = reach(path) orelse return null;
const answered = transact(registry.handle, .open, registry.path(), null) orelse return null; const answered = transact(.open, registry.handle, .{ .flags = 0 }, registry.path(), null) orelse return null;
if (answered.reply.status != 0) return null; if (answered.status.status != 0) {
// A refusal carries no channel; anything that arrived anyway would be a
// handle-table slot spent for nothing.
if (answered.capability) |handle| _ = ipc.close(handle);
return null;
}
// The capability *is* the channel an open that succeeds without one was // The capability *is* the channel an open that succeeds without one was
// answered by a file backend, which does not speak protocols. // answered by a file backend, which does not speak protocols.
return answered.capability; return answered.capability;
@ -257,8 +253,8 @@ pub fn openEndpoint(name: []const u8) ?ipc.Handle {
/// `-EBUSY` a live provider already holds it. /// `-EBUSY` a live provider already holds it.
pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 { pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 {
const registry = reach(root) orelse return null; const registry = reach(root) orelse return null;
const answered = transact(registry.handle, .bind, name, endpoint) orelse return null; const answered = transact(.bind, registry.handle, {}, name, endpoint) orelse return null;
return answered.reply.status; return answered.status.status;
} }
/// How long a provider keeps offering itself before giving up. The registry is /// How long a provider keeps offering itself before giving up. The registry is

View File

@ -14,8 +14,13 @@ const std = @import("std");
const abi = @import("abi"); const abi = @import("abi");
const sc = @import("system-call"); const sc = @import("system-call");
const ipc = @import("ipc"); const ipc = @import("ipc");
const envelope = @import("envelope");
const vfs_protocol = @import("vfs-protocol"); const vfs_protocol = @import("vfs-protocol");
/// The generated vfs contract: encode/decode for every verb, with the node id
/// carried in the packet header's `target`.
const Protocol = vfs_protocol.Protocol;
/// The kind of a filesystem node re-exported so a caller need not import the /// The kind of a filesystem node re-exported so a caller need not import the
/// wire protocol. /// wire protocol.
pub const Kind = vfs_protocol.NodeKind; pub const Kind = vfs_protocol.NodeKind;
@ -89,23 +94,24 @@ fn resolve(path: []const u8, flags: usize) ?Route {
} }
} }
const Result = struct { reply: vfs_protocol.Reply, payload: []u8 }; // One request/reply round trip: frame `[Header][request][tail]`, send it, and
// hand back the whole reply packet for the caller to decode with the generated
// One request/reply round trip: [Request header][send payload] -> backend -> // helpers. A backend that refused (a negative status) reads as null, which is
// [Reply header][receive payload]. The receive payload lands in `out`. // what every caller here did with it anyway.
fn transact(h: ipc.Handle, request: vfs_protocol.Request, send: []const u8, out: []u8) ?Result { fn transact(
var message: [vfs_protocol.message_maximum]u8 = undefined; comptime operation: Protocol.Operation,
@memcpy(message[0..vfs_protocol.request_size], std.mem.asBytes(&request)); handle: ipc.Handle,
const slen = @min(send.len, vfs_protocol.maximum_payload); target: u64,
@memcpy(message[vfs_protocol.request_size..][0..slen], send[0..slen]); request: Protocol.RequestOf(operation),
tail: []const u8,
var rbuf: [vfs_protocol.message_maximum]u8 = undefined; reply: []u8,
const n = ipc.call(h, message[0 .. vfs_protocol.request_size + slen], &rbuf) catch return null; ) ?[]u8 {
if (n < vfs_protocol.reply_size) return null; var packet: [vfs_protocol.message_maximum]u8 = undefined;
const reply = std.mem.bytesToValue(vfs_protocol.Reply, rbuf[0..vfs_protocol.reply_size]); const framed = Protocol.encodeRequest(operation, target, request, tail, &packet) orelse return null;
const rpl = @min(n - vfs_protocol.reply_size, out.len); const n = ipc.call(handle, framed, reply) catch return null;
@memcpy(out[0..rpl], rbuf[vfs_protocol.reply_size..][0..rpl]); const status = envelope.statusOf(reply[0..n]) orelse return null;
return .{ .reply = reply, .payload = out[0..rpl] }; if (status.status != 0) return null;
return reply[0..n];
} }
/// An open file: a VFS node plus a byte cursor. Read and write advance the cursor. /// An open file: a VFS node plus a byte cursor. Read and write advance the cursor.
@ -125,11 +131,13 @@ pub const File = struct {
return n; return n;
}; };
const want: u32 = @intCast(@min(buffer.len, vfs_protocol.maximum_payload)); const want: u32 = @intCast(@min(buffer.len, vfs_protocol.maximum_payload));
const request = vfs_protocol.Request{ .operation = .read, .node = self.node, .offset = self.offset, .len = want, .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
const r = transact(h, request, &.{}, buffer) orelse return null; const answered = transact(.read, h, self.node, .{ .offset = self.offset, .len = want }, &.{}, &reply) orelse return null;
if (r.reply.status != 0) return null; const bytes = Protocol.replyTail(.read, answered);
self.offset += r.reply.len; const n = @min(bytes.len, buffer.len);
return r.reply.len; @memcpy(buffer[0..n], bytes[0..n]);
self.offset += n;
return n;
} }
/// Write `data` at the current offset; returns the count written. A single /// Write `data` at the current offset; returns the count written. A single
@ -139,11 +147,11 @@ pub const File = struct {
pub fn write(self: *File, data: []const u8) ?usize { pub fn write(self: *File, data: []const u8) ?usize {
const h = self.backend orelse return null; const h = self.backend orelse return null;
const want: u32 = @intCast(@min(data.len, vfs_protocol.maximum_payload)); const want: u32 = @intCast(@min(data.len, vfs_protocol.maximum_payload));
const request = vfs_protocol.Request{ .operation = .write, .node = self.node, .offset = self.offset, .len = want, .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
const r = transact(h, request, data[0..want], &.{}) orelse return null; const answered = transact(.write, h, self.node, .{ .offset = self.offset, .len = want }, data[0..want], &reply) orelse return null;
if (r.reply.status != 0) return null; const written = Protocol.decodeReply(.write, answered) orelse return null;
self.offset += r.reply.len; self.offset += written.count;
return r.reply.len; return written.count;
} }
/// Write all of `data`, looping past the per-call payload cap. Returns the /// Write all of `data`, looping past the per-call payload cap. Returns the
@ -169,11 +177,9 @@ pub const File = struct {
const a = fsNodeStatus(self.node) orelse return null; const a = fsNodeStatus(self.node) orelse return null;
return .{ .size = a.size, .kind = if (a.kind == file_kind_directory) .directory else .regular, .mtime = a.mtime }; return .{ .size = a.size, .kind = if (a.kind == file_kind_directory) .directory else .regular, .mtime = a.mtime };
}; };
const request = vfs_protocol.Request{ .operation = .status, .node = self.node, .offset = 0, .len = 0, .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
var buffer: [@sizeOf(vfs_protocol.FileStatus)]u8 = undefined; const answered = transact(.status, h, self.node, {}, &.{}, &reply) orelse return null;
const r = transact(h, request, &.{}, &buffer) orelse return null; const status = Protocol.decodeReply(.status, answered) orelse return null;
if (r.reply.status != 0 or r.payload.len < @sizeOf(vfs_protocol.FileStatus)) return null;
const status = std.mem.bytesToValue(vfs_protocol.FileStatus, buffer[0..@sizeOf(vfs_protocol.FileStatus)]);
return .{ .size = status.size, .kind = kindFromWire(status.kind), .mtime = status.mtime }; return .{ .size = status.size, .kind = kindFromWire(status.kind), .mtime = status.mtime };
} }
@ -181,8 +187,8 @@ pub const File = struct {
/// tokens are permanent nothing to release. /// tokens are permanent nothing to release.
pub fn close(self: *File) void { pub fn close(self: *File) void {
const h = self.backend orelse return; const h = self.backend orelse return;
const request = vfs_protocol.Request{ .operation = .close, .node = self.node, .offset = 0, .len = 0, .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
_ = transact(h, request, &.{}, &.{}); _ = transact(.close, h, self.node, {}, &.{}, &reply);
} }
}; };
@ -193,10 +199,10 @@ pub fn open(path: []const u8, options: OpenOptions) ?File {
.kernel => |token| return .{ .node = token, .backend = null }, .kernel => |token| return .{ .node = token, .backend = null },
.backend => |b| { .backend => |b| {
const relative = route.backendPath(); const relative = route.backendPath();
const request = vfs_protocol.Request{ .operation = .open, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = options.wireFlags() }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
const r = transact(b.handle, request, relative, &.{}) orelse return null; const answered = transact(.open, b.handle, 0, .{ .flags = options.wireFlags() }, relative, &reply) orelse return null;
if (r.reply.status != 0) return null; const opened = Protocol.decodeReply(.open, answered) orelse return null;
return .{ .node = r.reply.node, .backend = b.handle }; return .{ .node = opened.node, .backend = b.handle };
}, },
} }
} }
@ -248,15 +254,13 @@ pub const Directory = struct {
self.cursor += 1; self.cursor += 1;
return true; return true;
}; };
const request = vfs_protocol.Request{ .operation = .readdir, .node = self.node, .offset = self.cursor, .len = 0, .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
var buffer: [vfs_protocol.message_maximum]u8 = undefined; const answered = transact(.readdir, h, self.node, .{ .cursor = self.cursor }, &.{}, &reply) orelse return false;
const r = transact(h, request, &.{}, &buffer) orelse return false; const header = Protocol.decodeReply(.readdir, answered) orelse return false;
if (r.reply.status != 0 or r.reply.len == 0) return false; // error or EOF if (header.name_len == 0) return false; // end of directory
if (r.payload.len < vfs_protocol.directory_entry_size) return false;
const header = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, r.payload[0..vfs_protocol.directory_entry_size]);
entry.kind = kindFromWire(header.kind); entry.kind = kindFromWire(header.kind);
entry.size = header.size; entry.size = header.size;
const source = r.payload[vfs_protocol.directory_entry_size..]; const source = Protocol.replyTail(.readdir, answered);
const nlen = @min(@min(@as(usize, header.name_len), source.len), entry.name_buffer.len); const nlen = @min(@min(@as(usize, header.name_len), source.len), entry.name_buffer.len);
@memcpy(entry.name_buffer[0..nlen], source[0..nlen]); @memcpy(entry.name_buffer[0..nlen], source[0..nlen]);
entry.name_len = nlen; entry.name_len = nlen;
@ -280,13 +284,11 @@ pub fn openDirectory(path: []const u8) ?Directory {
// A path-based request that returns only a status (mkdir, unlink). Kernel-served // A path-based request that returns only a status (mkdir, unlink). Kernel-served
// paths (the read-only /system) refuse mutation by construction: the resolve // paths (the read-only /system) refuse mutation by construction: the resolve
// must land on a backend. // must land on a backend.
fn pathOperation(operation: vfs_protocol.Operation, path: []const u8) bool { fn pathOperation(comptime operation: Protocol.Operation, path: []const u8) bool {
const route = resolve(path, 0) orelse return false; const route = resolve(path, 0) orelse return false;
if (route != .backend) return false; if (route != .backend) return false;
const relative = route.backendPath(); var reply: [vfs_protocol.message_maximum]u8 = undefined;
const request = vfs_protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = 0 }; return transact(operation, route.backend.handle, 0, {}, route.backendPath(), &reply) != null;
const r = transact(route.backend.handle, request, relative, &.{}) orelse return false;
return r.reply.status == 0;
} }
/// Create a directory at `path` (its parent must already exist). Returns true on /// Create a directory at `path` (its parent must already exist). Returns true on
@ -336,9 +338,8 @@ pub fn rename(old_path: []const u8, new_path: []const u8) bool {
@memcpy(payload[0..old_relative.len], old_relative); @memcpy(payload[0..old_relative.len], old_relative);
payload[old_relative.len] = 0; payload[old_relative.len] = 0;
@memcpy(payload[old_relative.len + 1 ..][0..new_relative.len], new_relative); @memcpy(payload[old_relative.len + 1 ..][0..new_relative.len], new_relative);
const request = vfs_protocol.Request{ .operation = .rename, .node = 0, .offset = 0, .len = @intCast(total), .flags = 0 }; var reply: [vfs_protocol.message_maximum]u8 = undefined;
const r = transact(old_route.backend.handle, request, payload[0..total], &.{}) orelse return false; return transact(.rename, old_route.backend.handle, 0, {}, payload[0..total], &reply) != null;
return r.reply.status == 0;
} }
/// Mount a filesystem backend (its server endpoint) at absolute path `target`; /// Mount a filesystem backend (its server endpoint) at absolute path `target`;

View File

@ -1,51 +1,61 @@
//! The block-device wire protocol what a filesystem (the FAT server) says to a //! The block-device wire protocol what a filesystem (the FAT server) says to a
//! block driver (usb-storage) over its well-known `.block` endpoint. A protocol //! block driver (usb-storage) over `/protocol/block`. Defined through the
//! module like vfs-protocol / usb-transfer-protocol: extern-struct messages, an //! envelope, so every packet begins with the folded `Header`.
//! `Operation` tag, everything in one IPC message. //!
//! **`Header.target` is always 0 here**: a block driver instance serves exactly
//! one device over its own endpoint, so there is no object within the peer to
//! address. A driver that later fronts several volumes gives them target ids and
//! `enumerate` lists them; nothing else about the protocol changes.
//! //!
//! Data path: read and write move whole blocks to or from a **caller-owned DMA //! Data path: read and write move whole blocks to or from a **caller-owned DMA
//! buffer**, named by its physical address the same physical-address handoff //! buffer**, named by its physical address the same physical-address handoff
//! usb-storage already uses toward the controller, one layer up. So a 512-byte //! usb-storage already uses toward the controller, one layer up. So a 512-byte
//! sector never has to cross the 256-byte IPC boundary; only the small request / //! sector never has to cross the packet floor; only the small request / reply
//! reply headers do. Under an enforcing IOMMU the buffer's physical addresses are //! parts do. Under an enforcing IOMMU the buffer's physical addresses are only
//! only reachable by the device once the filesystem has `attach`ed the buffer's //! reachable by the device once the filesystem has `attach`ed the buffer's
//! capability (the block server forwards it to the controller); see docs/driver-model.md. //! capability (the block server forwards it to the controller); see
//! docs/driver-model.md.
pub const Operation = enum(u32) { const envelope = @import("envelope");
/// geometry() -> { block_size, block_count }
geometry = 0, /// The answer to `geometry()`.
/// read(lba, count, physical): read `count` blocks from `lba` into the buffer pub const Geometry = extern struct {
read = 1, block_size: u32, // bytes per block (512)
/// write(lba, count, physical): write `count` blocks at `lba` from the buffer _padding: u32 = 0,
write = 2, block_count: u64, // total blocks
/// flush(): commit any device write cache to stable media (no data transfer).
/// A filesystem calls this to make prior writes durable e.g. before power-off,
/// so a shutdown-time write isn't lost in the USB flash controller's cache.
flush = 3,
/// attach(): the caller's DMA-region capability rides the call's cap slot; the
/// block server forwards it to the controller so the buffer's physical addresses
/// (named in later read/write) are reachable by the device under an enforcing
/// IOMMU. Call once per buffer before using it in a transfer.
attach = 4,
}; };
pub const Request = extern struct { /// `read(lba, count, physical)` / `write(...)`: move `count` blocks between the
operation: u32, /// device and the caller's DMA buffer at `physical`.
reserved: u32 = 0, pub const Transfer = extern struct {
lba: u64, lba: u64,
count: u32, // number of blocks (read/write) count: u32,
reserved2: u32 = 0, _padding: u32 = 0,
physical: u64, // caller's DMA buffer physical address (read/write) physical: u64, // caller's DMA buffer physical address
}; };
pub const Reply = extern struct { /// How many blocks a transfer actually moved.
status: i32, // 0 on success, negative on failure pub const Transferred = extern struct { count: u32 };
reserved: u32 = 0,
block_size: u32, // geometry: bytes per block (512)
reserved2: u32 = 0,
block_count: u64, // geometry: total blocks; read/write: blocks moved
};
pub const message_maximum: usize = 256; pub const Protocol = envelope.Define(.{
pub const request_size: usize = @sizeOf(Request); .name = "block",
pub const reply_size: usize = @sizeOf(Reply); .version = 1,
.operations = &.{
.{ .name = "geometry", .reply = Geometry },
.{ .name = "read", .request = Transfer, .reply = Transferred },
.{ .name = "write", .request = Transfer, .reply = Transferred },
// flush(): commit any device write cache to stable media (no data
// transfer). A filesystem calls this to make prior writes durable
// before power-off, so a shutdown-time write isn't lost in the USB flash
// controller's cache.
.{ .name = "flush" },
// attach(): the caller's DMA-region capability rides the call's cap
// slot; the block server forwards it to the controller so the buffer's
// physical addresses (named in later read/write) are reachable by the
// device under an enforcing IOMMU. Call once per buffer before using it.
.{ .name = "attach" },
},
});
pub const Operation = Protocol.Operation;
pub const message_maximum: usize = Protocol.message_maximum;

View File

@ -3,7 +3,8 @@
//! every conversation depend on the contract by name; neither reaches into the //! every conversation depend on the contract by name; neither reaches into the
//! other's files. Pure flat wire types: no protocol module imports anything. //! other's files. Pure flat wire types: no protocol module imports anything.
//! //!
//! One module here is not a protocol but the shape the others are written in: //! One module here is not a protocol but the shape the others are written in,
//! and therefore the one module every other one imports:
//! //!
//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md) //! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md)
//! //!
@ -19,10 +20,12 @@
const std = @import("std"); const std = @import("std");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
// Not a protocol, hence not `-protocol`: the envelope is what a protocol is
// defined *through*, so it is built first and handed to every protocol
// below as their one import.
const envelope = b.addModule("envelope", .{ .root_source_file = b.path("envelope/envelope.zig") });
for ([_]struct { name: []const u8, root: []const u8 }{ for ([_]struct { name: []const u8, root: []const u8 }{
// Not a protocol, hence not `-protocol`: the envelope is what a
// protocol is defined *through*.
.{ .name = "envelope", .root = "envelope/envelope.zig" },
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" }, .{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
.{ .name = "input-protocol", .root = "input/input-protocol.zig" }, .{ .name = "input-protocol", .root = "input/input-protocol.zig" },
.{ .name = "block-protocol", .root = "block/block-protocol.zig" }, .{ .name = "block-protocol", .root = "block/block-protocol.zig" },
@ -32,21 +35,35 @@ pub fn build(b: *std.Build) void {
.{ .name = "scanout-protocol", .root = "scanout/scanout-protocol.zig" }, .{ .name = "scanout-protocol", .root = "scanout/scanout-protocol.zig" },
.{ .name = "power-protocol", .root = "power/power-protocol.zig" }, .{ .name = "power-protocol", .root = "power/power-protocol.zig" },
}) |protocol| { }) |protocol| {
_ = b.addModule(protocol.name, .{ .root_source_file = b.path(protocol.root) }); _ = b.addModule(protocol.name, .{
.root_source_file = b.path(protocol.root),
.imports = &.{.{ .name = "envelope", .module = envelope }},
});
} }
// Standalone `zig build test` for this domain alone; the root build keeps // Standalone `zig build test` for this domain alone; the root build keeps
// its aggregate test step. // its aggregate test step.
const test_step = b.step("test", "Run the protocol unit tests"); const test_step = b.step("test", "Run the protocol unit tests");
// The envelope tests itself with no import of its own everything else
// imports it, so it is built separately rather than importing itself.
const envelope_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("envelope/envelope.zig"), // framing round trips, verb numbering, dispatch, the floors
.target = b.resolveTargetQuery(.{}),
}),
});
test_step.dependOn(&b.addRunArtifact(envelope_tests).step);
for ([_][]const u8{ for ([_][]const u8{
"envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors
"vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values "vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"input/input-protocol.zig", // event numbering + the push-floor budget
"display/display-protocol.zig", // pack(): native pixel encoding per format "display/display-protocol.zig", // pack(): native pixel encoding per format
}) |root| { }) |root| {
const protocol_tests = b.addTest(.{ const protocol_tests = b.addTest(.{
.root_module = b.createModule(.{ .root_module = b.createModule(.{
.root_source_file = b.path(root), .root_source_file = b.path(root),
.target = b.resolveTargetQuery(.{}), .target = b.resolveTargetQuery(.{}),
.imports = &.{.{ .name = "envelope", .module = envelope }},
}), }),
}); });
test_step.dependOn(&b.addRunArtifact(protocol_tests).step); test_step.dependOn(&b.addRunArtifact(protocol_tests).step);

View File

@ -1,93 +1,137 @@
//! The display wire protocol what a client says to the display service over its //! The display wire protocol what a client says to the display service over
//! well-known `.display` endpoint. extern-struct messages with an `Operation` tag, the //! `/protocol/display`. The compositor owns the framebuffer and an ordered stack of
//! same shape as block/vfs/input protocols. The compositor owns the framebuffer and an //! **layers**; a client creates layers, draws into them with these operations, marks damage,
//! ordered stack of **layers**; a client creates layers, draws into them with these //! and asks for a `present`. v1 surfaces are server-owned (a client draws by command);
//! operations, marks damage, and asks for a `present`. v1 surfaces are server-owned (a //! shared-memory surfaces are a later milestone (docs/display.md).
//! client draws by command); shared-memory surfaces are a later milestone (docs/display.md). //!
//! **`Header.target` is the layer** on every verb that names one the field that used to be
//! `Request.layer`. `info`, `present`, `set_mode`, `get_modes` and `attach_scanout` address
//! the compositor itself, so they leave it 0.
//!
//! Every verb carries its own request type. The single overloaded 40-byte request this
//! protocol used to have is gone, and with it the field abuse it invited: `attach_scanout`
//! spent `x` on a stride, `y` on a refresh rate and `colour` on a pixel format, which no
//! reader could have guessed and no compiler could have caught.
const envelope = @import("envelope");
const std = @import("std"); const std = @import("std");
pub const Operation = enum(u32) { /// The answer to `info()`: the display's current mode.
/// info() -> { width, height, pitch, format }: the display's current mode. pub const Info = extern struct {
info = 0,
/// create_layer(x, y, width, height, z) -> { layer }: a new server-owned surface.
create_layer = 1,
/// configure_layer(layer, x, y, z, visible): move, restack, show, or hide a layer.
configure_layer = 2,
/// destroy_layer(layer): release a layer.
destroy_layer = 3,
/// fill_rect(layer, x, y, width, height, colour): fill a rectangle of a layer.
fill_rect = 4,
/// blit_tile(layer, x, y, width, height, <inline pixels>): copy a small pixel tile in.
blit_tile = 5,
/// damage(layer, x, y, width, height): mark a region dirty for the next present.
damage = 6,
/// present(): composite the dirty layers and flush to the screen.
present = 7,
/// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
/// capability>: a native scanout driver announces itself, handing over the shared scanout
/// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
/// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
/// `x` is the surface's row stride in pixels, `y` the panel refresh rate from the
/// driver's EDID read (0 = unknown; paces the compositor's frame clock), `colour` the
/// DisplayFormat.
attach_scanout = 8,
/// set_mode(width, height): change the display resolution only a native backend that
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
set_mode = 9,
/// get_modes() -> ModesReply: the resolutions the display can switch to (empty on GOP).
get_modes = 10,
};
/// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels)
/// follows this header inline in the same message, up to `maximum_payload`.
pub const Request = extern struct {
operation: u32,
layer: u32 = 0, // create/configure/destroy/fill/blit/damage: the target layer
x: u32 = 0,
y: u32 = 0,
width: u32 = 0, width: u32 = 0,
height: u32 = 0, height: u32 = 0,
z: u32 = 0, // create_layer / configure_layer: stacking order (higher = in front) pitch: u32 = 0, // bytes per row (may exceed width*4)
colour: u32 = 0, // fill_rect: the fill colour (native pixel value)
visible: u32 = 1, // configure_layer: 0 hides the layer
reserved: u32 = 0,
};
pub const Reply = extern struct {
status: i32, // 0 on success, negative on failure
reserved: u32 = 0,
// info():
width: u32 = 0,
height: u32 = 0,
pitch: u32 = 0,
format: u32 = 0, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx) format: u32 = 0, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
// create_layer():
layer: u32 = 0,
reserved2: u32 = 0,
}; };
/// `create_layer(...)`: a new server-owned surface. Coordinates are signed a layer may sit
/// partly off-screen.
pub const CreateLayer = extern struct {
x: i32,
y: i32,
width: u32,
height: u32,
z: u32 = 0, // stacking order (higher = nearer the front)
visible: u32 = 1,
};
/// The layer a `create_layer` established the integer later packets put in `Header.target`.
pub const Created = extern struct { layer: u32 };
/// `configure_layer(...)` on `Header.target`: move, restack, show, or hide it.
pub const ConfigureLayer = extern struct {
x: i32,
y: i32,
z: u32 = 0,
visible: u32 = 1, // 0 hides the layer
};
/// `fill_rect(...)` on `Header.target`: fill a layer-local rectangle with a native pixel value.
pub const FillRect = extern struct {
x: i32,
y: i32,
width: u32,
height: u32,
colour: u32,
};
/// `blit_tile(...)` on `Header.target`: copy a `width`×`height` tile of native pixels
/// (row-major, little-endian) into the layer. The pixels ride inline as the packet's tail,
/// up to `maximum_payload`.
pub const BlitTile = extern struct {
x: i32,
y: i32,
width: u32,
height: u32,
};
/// `damage(...)` on `Header.target`: mark a layer-local region dirty for the next present.
pub const Damage = extern struct {
x: i32,
y: i32,
width: u32,
height: u32,
};
/// `attach_scanout(...)` + the shared surface as the call's capability: a native scanout
/// driver announces itself. The compositor maps the surface, opens the driver's
/// `/protocol/scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md
/// V4). Each field says what it is, which the old shared request could not.
pub const AttachScanout = extern struct {
/// The surface's row stride in pixels (it is sized to the driver's largest mode).
stride: u32,
/// The active mode within that surface.
width: u32,
height: u32,
/// A device-abi DisplayFormat value.
format: u32,
/// The panel refresh rate from the driver's EDID read (0 = unknown); it paces the
/// compositor's frame clock.
refresh_hz: u32 = 0,
};
/// `set_mode(width, height)`: change the display resolution only a native backend that
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
pub const SetMode = extern struct { width: u32, height: u32 };
/// One selectable display mode. /// One selectable display mode.
pub const Mode = extern struct { width: u32, height: u32 }; pub const Mode = extern struct { width: u32, height: u32 };
pub const max_modes = 4; pub const max_modes = 4;
/// The reply to `get_modes`: a small fixed list of resolutions the display can switch to. /// The answer to `get_modes`: the resolutions the display can switch to (empty on GOP).
pub const ModesReply = extern struct { pub const Modes = extern struct {
status: i32, count: u32 = 0,
count: u32, _padding: u32 = 0,
modes: [max_modes]Mode, modes: [max_modes]Mode = @splat(.{ .width = 0, .height = 0 }),
}; };
pub const modes_reply_size: usize = @sizeOf(ModesReply);
/// The IPC message size the kernel caps every message at `MESSAGE_MAXIMUM` (256 bytes, pub const Protocol = envelope.Define(.{
/// system/kernel/ipc-synchronous.zig), so this matches it (a larger receive/reply buffer .name = "display",
/// is rejected with -E2BIG). A `blit_tile` therefore carries only a *small* tile inline .version = 1,
/// `maximum_payload` bytes = up to 54 pixels, enough for a cursor or small sprite; larger .operations = &.{
/// bitmaps are the deferred shared-memory surface path (docs/display.md). .{ .name = "info", .reply = Info },
pub const message_maximum: usize = 256; .{ .name = "create_layer", .request = CreateLayer, .reply = Created },
pub const request_size: usize = @sizeOf(Request); .{ .name = "configure_layer", .request = ConfigureLayer },
pub const reply_size: usize = @sizeOf(Reply); .{ .name = "destroy_layer" },
pub const maximum_payload: usize = message_maximum - request_size; .{ .name = "fill_rect", .request = FillRect },
.{ .name = "blit_tile", .request = BlitTile },
.{ .name = "damage", .request = Damage },
.{ .name = "present" },
.{ .name = "attach_scanout", .request = AttachScanout },
.{ .name = "set_mode", .request = SetMode },
.{ .name = "get_modes", .reply = Modes },
},
});
pub const Operation = Protocol.Operation;
pub const message_maximum: usize = Protocol.message_maximum;
/// The largest inline pixel tile a `blit_tile` may carry: the call floor less the header and
/// this verb's own fixed part 224 bytes, up to 56 pixels, enough for a cursor or a small
/// sprite. Larger bitmaps are the deferred shared-memory surface path (docs/display.md).
/// Per-verb rather than protocol-wide, because with per-operation requests there is no
/// single "request size" to subtract any more.
pub const maximum_payload: usize = envelope.packet_maximum - envelope.prefix_size - @sizeOf(BlitTile);
/// Pack an 8-bit-per-channel colour into the display's native 32-bit pixel for `format` /// Pack an 8-bit-per-channel colour into the display's native 32-bit pixel for `format`
/// (a device-abi `DisplayFormat`: 0 = rgbx, 1 = bgrx). Shared so a `colour` in a /// (a device-abi `DisplayFormat`: 0 = rgbx, 1 = bgrx). Shared so a `colour` in a
@ -113,3 +157,15 @@ test "pack encodes native byte order for rgbx and bgrx" {
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(1, 0xAA, 0, 0)); try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(1, 0xAA, 0, 0));
try std.testing.expectEqual(@as(u32, 0x0000_3020), pack(0, 0x20, 0x30, 0)); // green in byte 1 try std.testing.expectEqual(@as(u32, 0x0000_3020), pack(0, 0x20, 0x30, 0)); // green in byte 1
} }
test "the layer rides the header, and the blit tile grew with the split" {
var buffer: [message_maximum]u8 = undefined;
const pixels = [_]u8{0xFF} ** 16;
const packet = Protocol.encodeRequest(.blit_tile, 3, .{ .x = 1, .y = 2, .width = 2, .height = 2 }, &pixels, &buffer).?;
try std.testing.expectEqual(@as(u64, 3), envelope.headerOf(packet).?.target);
try std.testing.expectEqual(@as(i32, 1), Protocol.decodeRequest(.blit_tile, packet).?.x);
try std.testing.expectEqual(@as(usize, 16), Protocol.requestTail(.blit_tile, packet).len);
// 216 bytes under the old 40-byte shared request; the header plus this
// verb's own four fields is 32.
try std.testing.expectEqual(@as(usize, 224), maximum_payload);
}

View File

@ -4,25 +4,29 @@
//! **subscriber** (any program) that subscribes and is then pushed each event. //! **subscriber** (any program) that subscribes and is then pushed each event.
//! //!
//! The service handles several device classes over one endpoint. Each class has its own //! The service handles several device classes over one endpoint. Each class has its own
//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); they all travel in a common //! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); a subscriber declares which
//! `InputEvent` envelope tagged with a `DeviceKind`, so the fan-out path is one code path //! classes it wants with a `device_mask`, and the service routes accordingly.
//! and a subscriber can take a mix of devices on a single stream. A subscriber declares
//! which classes it wants with a `device_mask`, and the service routes accordingly.
//! //!
//! Two message shapes ride over the endpoint, tagged by `Operation`, like the //! Three shapes ride over the channel, and the envelope names all three
//! [VFS protocol](../vfs/protocol.zig): //! (docs/os-development/protocol-namespace.md):
//! //!
//! - **subscribe / publish**: a synchronous `ipc_call` carrying a `Request`. `subscribe` //! - **subscribe** is the *reserved* verb, not one of this protocol's own: its shape a
//! hands the service the subscriber's own endpoint as a capability (`send_cap`) and a //! synchronous call whose attached capability is the subscriber's endpoint is exactly
//! `device_mask`; `publish` carries an `InputEvent`. The reply is a `Reply`. //! what `envelope.operation_subscribe` means everywhere. The interest mask travels as the
//! - **delivery**: the service pushes each `InputEvent` to every interested subscriber with //! packet's tail (`Subscribe`), because a reserved verb carries no typed request.
//! the asynchronous `ipc_send` no reply owed, and a dead subscriber can never stall the //! - **publish** is this protocol's one verb: a source sends one `InputEvent` and the
//! broadcast. Received in the subscriber's buffer with `Received.isMessage()` set. //! service answers at once, so publishing never blocks on a slow subscriber.
//! - **delivery** is an event push: the service `ipc_send`s each event to every interested
//! subscriber no reply owed, so a dead subscriber can never stall the broadcast. The
//! packet is the folded header plus the typed event, and **the device class is the
//! header's operation**: one event per class, so a subscriber reads the kind from the
//! packet rather than from a tag inside the payload.
//! //!
//! This is a danos-native contract, shared by the input service, the `runtime.input` //! `Header.target` is unused (0) in both directions: the service is the only object either
//! client helpers, and every source/subscriber. Everything fits one IPC message. //! side addresses.
const std = @import("std"); const std = @import("std");
const envelope = @import("envelope");
/// The classes of input device the service fans out. Each names a typed event and a bit in /// The classes of input device the service fans out. Each names a typed event and a bit in
/// the subscription mask. /// the subscription mask.
@ -242,14 +246,17 @@ pub const JoystickEvent = extern struct {
buttons: u32, // current pressed-button bitmask buttons: u32, // current pressed-button bitmask
}; };
// --- the common envelope ---------------------------------------------------- // --- the tagged union of the three ------------------------------------------
/// The largest per-device event, so `InputEvent` can hold any of them inline. /// The largest per-device event, so `InputEvent` can hold any of them inline.
pub const max_event_size: usize = @max(@sizeOf(KeyEvent), @max(@sizeOf(MouseEvent), @sizeOf(JoystickEvent))); pub const max_event_size: usize = @max(@sizeOf(KeyEvent), @max(@sizeOf(MouseEvent), @sizeOf(JoystickEvent)));
/// The tagged envelope broadcast to subscribers: a `DeviceKind` plus the raw bytes of the /// One event of any class: a `DeviceKind` plus the raw bytes of the matching per-device
/// matching per-device event. Decode it with `asKeyboard`/`asMouse`/`asJoystick` (each /// event. This is what a source `publish`es (one verb for all three classes) and what a
/// returns null unless `device` matches), or build one with the `from*` constructors. /// subscriber's helper hands back after decoding a delivery on the *delivery* wire the
/// class is the packet header's operation instead, so this tag never travels there. Decode
/// it with `asKeyboard`/`asMouse`/`asJoystick` (each returns null unless `device` matches),
/// or build one with the `from*` constructors.
pub const InputEvent = extern struct { pub const InputEvent = extern struct {
device: u32, // a DeviceKind device: u32, // a DeviceKind
_padding: u32 = 0, _padding: u32 = 0,
@ -285,35 +292,103 @@ pub const InputEvent = extern struct {
} }
}; };
// --- request / reply -------------------------------------------------------- // --- the contract -----------------------------------------------------------
/// Which side of a request this is. /// The body of a `subscribe` the envelope's reserved verb 2, whose shape (a call whose
pub const Operation = enum(u32) { /// capability is the subscriber's own endpoint) this protocol adopts wholesale. A reserved
subscribe = 0, // register the caller's endpoint (send_cap) for the classes in device_mask /// verb has no typed request, so the mask travels as the packet's tail and `encodeSubscribe`
publish = 1, // a source submits `event` to broadcast to interested subscribers /// is how a client lays it down. Zero means every class.
}; pub const Subscribe = extern struct { device_mask: u32 = 0 };
/// Request header. For `subscribe`, `device_mask` is the OR of `device_*` bits the caller pub const Protocol = envelope.Define(.{
/// wants (0 means all) and the caller's receive endpoint travels as the call's capability; .name = "input",
/// `event` is ignored. For `publish`, `event` is the event to broadcast. .version = 1,
pub const Request = extern struct { .operations = &.{
operation: u32, // an Operation // A source submits one event; the service broadcasts it to whoever wants that class.
device_mask: u32 = 0, // subscribe: interested device classes (0 => all) .{ .name = "publish", .request = InputEvent },
event: InputEvent = .{ .device = 0 }, },
}; .events = &.{
// One per device class: the class is the packet's operation, the typed event its
// payload. The push floor is 64 bytes and the header spends 16 of them, so the
// widest of these the 28-byte mouse event leaves the budget with room to spare.
.{ .name = "keyboard", .payload = KeyEvent },
.{ .name = "mouse", .payload = MouseEvent },
.{ .name = "joystick", .payload = JoystickEvent },
},
});
/// Reply header. `status` is 0 on success or a negative errno. pub const Operation = Protocol.Operation;
pub const Reply = extern struct { pub const Event = Protocol.Event;
status: i32, pub const message_maximum: usize = Protocol.message_maximum;
_padding: u32 = 0,
};
pub const request_size: usize = @sizeOf(Request);
pub const reply_size: usize = @sizeOf(Reply);
pub const event_size: usize = @sizeOf(InputEvent); pub const event_size: usize = @sizeOf(InputEvent);
comptime { /// The event class a `DeviceKind` value (as it appears in `InputEvent.device`) is delivered
// The delivery path posts a bare InputEvent through ipc_send, so it must fit an /// as. Null for a value no class claims, which is delivered to nobody.
// endpoint's async payload slot (POST_MAXIMUM is 64). pub fn eventOfDevice(device: u32) ?Event {
if (event_size > 64) @compileError("InputEvent must fit the ipc_send payload (POST_MAXIMUM)"); return switch (device) {
@intFromEnum(DeviceKind.keyboard) => .keyboard,
@intFromEnum(DeviceKind.mouse) => .mouse,
@intFromEnum(DeviceKind.joystick) => .joystick,
else => null,
};
}
/// Frame a `subscribe` request: the reserved verb's header, then the interest mask. Null if
/// the buffer is too small. Spelled here rather than at each caller so the one place that
/// knows a reserved verb carries its body in the tail is the protocol module.
pub fn encodeSubscribe(device_mask: u32, buffer: []u8) ?[]u8 {
const total = envelope.prefix_size + @sizeOf(Subscribe);
if (buffer.len < total) return null;
const header = envelope.Header{ .operation = envelope.operation_subscribe };
const body = Subscribe{ .device_mask = device_mask };
@memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header));
@memcpy(buffer[envelope.prefix_size..][0..@sizeOf(Subscribe)], std.mem.asBytes(&body));
return buffer[0..total];
}
/// The interest mask out of a `subscribe` packet's tail, on the provider's side. A caller
/// that sent no mask at all means every class, which is what a zero mask means anyway.
pub fn decodeSubscribe(tail: []const u8) Subscribe {
if (tail.len < @sizeOf(Subscribe)) return .{};
return std.mem.bytesToValue(Subscribe, tail[0..@sizeOf(Subscribe)]);
}
test "an event of every class fits the push floor, header included" {
// What the hand-rolled comptime assert used to say about `InputEvent`, now
// said by `Define` about each typed event and counting the header, which
// the old check did not.
try std.testing.expectEqual(envelope.prefix_size + @sizeOf(MouseEvent), Protocol.event_maximum);
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
}
test "the verb numbering, and the class an event carries" {
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.publish));
// Events number in their own space, so the three classes start at 16 too.
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.keyboard));
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.mouse));
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Event.joystick));
// subscribe is the RESERVED verb, below the protocol range entirely.
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
var buffer: [envelope.post_maximum]u8 = undefined;
const packet = Protocol.encodeEvent(.mouse, 0, .{
.kind = @intFromEnum(MouseEventKind.motion),
.button = 0,
.dx = 3,
.dy = -4,
.scroll_x = 0,
.scroll_y = 0,
.buttons = 0,
}, &buffer).?;
try std.testing.expectEqual(Event.mouse, Protocol.eventOf(packet).?);
try std.testing.expectEqual(@as(i32, -4), Protocol.decodeEvent(.mouse, packet).?.dy);
}
test "a subscribe carries its mask in the tail of the reserved verb" {
var buffer: [envelope.packet_maximum]u8 = undefined;
const packet = encodeSubscribe(device_mouse, &buffer).?;
try std.testing.expectEqual(envelope.operation_subscribe, envelope.headerOf(packet).?.operation);
try std.testing.expectEqual(device_mouse, decodeSubscribe(packet[envelope.prefix_size..]).device_mask);
// A caller that sent nothing at all reads as the every-class mask.
try std.testing.expectEqual(@as(u32, 0), decodeSubscribe(&.{}).device_mask);
} }

View File

@ -1,49 +1,55 @@
//! The scanout wire protocol what the compositor says to a native scanout driver (e.g. //! The scanout wire protocol what the compositor says to a native scanout driver (e.g.
//! virtio-gpu) over its well-known `.scanout` endpoint to put a composited frame on screen. //! virtio-gpu) over `/protocol/scanout` to put a composited frame on screen. The driver owns
//! The driver owns the panel and the shared scanout surface it handed the compositor (via the //! the panel and the shared scanout surface it handed the compositor (via the display
//! display service's `attach_scanout`); the compositor composites into that surface, then asks //! service's `attach_scanout`); the compositor composites into that surface, then asks the
//! the driver to present a damaged rectangle. Tiny by design one present request. Separate //! driver to present a damaged rectangle. Tiny by design one present request. Separate from
//! from the display protocol because the directions differ: clients call the compositor over //! the display protocol because the directions differ: clients call the compositor over
//! `.display`; the compositor calls the driver over `.scanout`. See docs/display-v2.md. //! `/protocol/display`; the compositor calls the driver over `/protocol/scanout`. See
//! docs/display-v2.md.
//!
//! One scanout per driver instance, so `Header.target` is always 0.
const std = @import("std"); const envelope = @import("envelope");
pub const Operation = enum(u32) { /// `present(rect)`: put the given rectangle of the shared scanout surface on the panel (on
/// present(x, y, width, height): put the given rectangle of the shared scanout surface on /// virtio-gpu: transfer-to-host of the region, then a fenced resource flush).
/// the panel (on virtio-gpu: transfer-to-host of the region, then a fenced resource flush). pub const Present = extern struct {
present = 0,
/// get_modes() -> ModesReply: the display modes this scanout can switch to (V5).
get_modes = 1,
/// set_mode(width, height): change the scanout resolution the shared surface is sized to
/// the largest mode, so this just re-points the scanout rectangle; the surface is unchanged.
set_mode = 2,
};
pub const Request = extern struct {
operation: u32,
x: u32 = 0, x: u32 = 0,
y: u32 = 0, y: u32 = 0,
width: u32 = 0, width: u32 = 0,
height: u32 = 0, height: u32 = 0,
}; };
pub const Reply = extern struct { /// `set_mode(width, height)`: change the scanout resolution the shared surface is sized to
status: i32, // 0 on success, negative on failure /// the largest mode, so this just re-points the scanout rectangle; the surface is unchanged.
reserved: u32 = 0, pub const SetMode = extern struct { width: u32, height: u32 };
};
/// One offered display mode. /// One offered display mode.
pub const Mode = extern struct { width: u32, height: u32 }; pub const Mode = extern struct { width: u32, height: u32 };
pub const max_modes = 4; pub const max_modes = 4;
/// The reply to `get_modes`: a small fixed list of modes. /// The answer to `get_modes`: a small fixed list of modes. The success/failure verdict is
pub const ModesReply = extern struct { /// the reply's `Status`, so this carries only the modes.
status: i32, pub const Modes = extern struct {
count: u32, count: u32 = 0,
modes: [max_modes]Mode, _padding: u32 = 0,
modes: [max_modes]Mode = @splat(.{ .width = 0, .height = 0 }),
}; };
pub const message_maximum: usize = 64; pub const Protocol = envelope.Define(.{
pub const request_size: usize = @sizeOf(Request); .name = "scanout",
pub const reply_size: usize = @sizeOf(Reply); .version = 1,
pub const modes_reply_size: usize = @sizeOf(ModesReply); .operations = &.{
.{ .name = "present", .request = Present },
.{ .name = "get_modes", .reply = Modes },
.{ .name = "set_mode", .request = SetMode },
},
});
pub const Operation = Protocol.Operation;
/// The call floor, like every synchronous protocol. This module used to declare
/// 64 the *push* floor which was simply wrong: nothing here is pushed, and a
/// provider sizing its receive buffer to 64 refuses (`-E2BIG`) any caller that
/// sends up to the floor it is entitled to.
pub const message_maximum: usize = Protocol.message_maximum;

View File

@ -1,52 +1,27 @@
//! The VFS wire protocol the message format spoken between a client (via the file //! The VFS wire protocol what a client (through the file API,
//! API) and the user-space VFS server over IPC. A request is a fixed `Request` header //! library/kernel/file-system.zig) says to a filesystem backend over IPC. Defined
//! followed by an inline payload (a path, or write bytes); a reply is a fixed `Reply` //! through the envelope (docs/os-development/protocol-namespace.md), so every
//! header followed by an inline payload (read bytes, or a FileStatus). Everything fits //! packet begins with the folded `Header`: the verb in `Header.operation`, and
//! in one IPC message (<= ipc MESSAGE_MAXIMUM = 256 bytes). //! **the open node id in `Header.target`** the field that used to be
//! `Request.node`. A path appears in the conversation once, at `open`; every
//! packet after it addresses that integer.
//! //!
//! This is a danos-native contract, so it uses danos names throughout. The client //! This is a danos-native contract, so it uses danos names throughout. It is
//! side is `runtime.fs` (library/runtime/fs.zig), which programs use directly. //! user-space only the kernel knows nothing of files or paths; it only routes
//! (`fs_resolve`) and moves the bytes. The backends that serve it today are the
//! FAT server (system/services/fat/) and the protocol registry inside PID 1
//! (system/services/init/), which is a *synthetic* backend: `/protocol` holds
//! contracts rather than files.
//! //!
//! This is user-space only the kernel knows nothing of files or paths; it only moves the bytes. //! **An `open` reply may carry a capability.** The `open` request rides
//! Shared by library/runtime/fs.zig (the client) and the mount backends that serve it (today
//! the fat server, system/services/fat/). The standalone user-space VFS server it was first
//! written against has retired path routing moved into the kernel (system/kernel/vfs.zig,
//! fs_resolve) but the protocol module outlived it.
//! **An `open` reply may carry a capability.** The vfs `open` request rides
//! `ipc_call`, and the reply direction of a call can hand back an endpoint //! `ipc_call`, and the reply direction of a call can hand back an endpoint
//! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it FAT answers //! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it FAT answers with
//! with a node id and nothing else but a *synthetic* backend does: opening a //! a node id and nothing else but the registry does: opening a
//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint, //! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint,
//! which is the channel (docs/os-development/protocol-namespace.md). The //! which is the channel. The convention is per-backend, not per-operation: a
//! convention is per-backend, not per-operation: a client that did not ask a //! client that did not ask a synthetic backend simply gets no capability back.
//! synthetic backend simply gets no capability back, exactly as today.
pub const Operation = enum(u32) { const envelope = @import("envelope");
open, // open(path) -> node id (a synthetic backend may reply with a capability instead)
close, // close(node)
read, // read(node, offset, len) -> bytes
write, // write(node, offset, bytes) -> count
status, // status(node) -> FileStatus
// Appended for the mount router (M5). Values stay stable, so existing clients
// and the flat-ramfs tests are unaffected.
readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF)
mount, // mount(prefix payload, capability = backend endpoint)
unmount, // unmount(prefix payload)
// Appended for filesystem mutation (Phase 2). Path-based (the path is the
// payload); a mounted backend handles them, the flat ramfs refuses them.
mkdir, // mkdir(path payload) -> status
unlink, // unlink(path payload) -> status
// rename: the payload is the old path, a single 0x00 separator, then the new
// path. Same-directory rename only (the router requires both under one mount).
rename, // rename(old\0new payload) -> status
// Appended for the protocol namespace (P2). The registry is a synthetic
// backend mounted at /protocol: `open` establishes a channel and `readdir`
// lists the bound names like any directory, so those two verbs need nothing
// new but *claiming* a name does. A file backend refuses it, alongside the
// router verbs it does not implement either; only the registry implements it.
bind, // bind(name payload, capability = the provider's endpoint) -> status
};
/// The type of a filesystem node, aligned to the node-kind table /// The type of a filesystem node, aligned to the node-kind table
/// (docs/file-system-development/file-system-hierarchy.md). Fills `FileStatus.kind` and /// (docs/file-system-development/file-system-hierarchy.md). Fills `FileStatus.kind` and
@ -67,38 +42,18 @@ pub const NodeKind = enum(u32) {
protocol = 7, protocol = 7,
}; };
/// One directory entry, returned by `readdir`: a fixed header followed inline in /// One directory entry: the fixed part of a `readdir` reply, followed inline by
/// the reply payload by `name_len` bytes of name. A zero-length reply is EOF. /// `name_len` bytes of name. **A zero `name_len` is end of directory** the
/// reply's own length cannot say so any more, because the envelope always sends
/// the fixed part.
pub const DirectoryEntry = extern struct { pub const DirectoryEntry = extern struct {
kind: u32, // a NodeKind kind: u32 = 0, // a NodeKind
name_len: u32, name_len: u32 = 0,
size: u64, size: u64 = 0,
}; };
pub const directory_entry_size: usize = @sizeOf(DirectoryEntry); pub const directory_entry_size: usize = @sizeOf(DirectoryEntry);
/// Request header. `node` is the server-side open-file id (from a prior open);
/// for `open` the path is the payload and `len` is its length. `offset`/`len`
/// carry the read/write position and count.
pub const Request = extern struct {
operation: Operation,
node: u64,
offset: u64,
len: u32,
flags: u32,
};
/// Reply header. `status` is 0 on success or a negative errno; `node` is the new
/// open-file id (for `open`); `len` is the payload length (bytes read, or the
/// FileStatus size).
pub const Reply = extern struct {
status: i32,
_padding: u32 = 0,
node: u64 = 0,
len: u32 = 0,
_padding2: u32 = 0,
};
/// A file's metadata (the danos-native answer to a `status` request). The POSIX /// A file's metadata (the danos-native answer to a `status` request). The POSIX
/// layer maps this onto `struct stat`. /// layer maps this onto `struct stat`.
pub const FileStatus = extern struct { pub const FileStatus = extern struct {
@ -110,13 +65,84 @@ pub const FileStatus = extern struct {
mtime: u64 = 0, mtime: u64 = 0,
}; };
pub const message_maximum: usize = 256; // --- the per-operation request and reply parts ------------------------------
pub const request_size: usize = @sizeOf(Request); //
pub const reply_size: usize = @sizeOf(Reply); // Each names the bytes AFTER the prefix. Nothing here carries an operation or a
/// Largest inline payload that still fits one IPC message alongside a header. // node id: those are the packet header's, folded in once.
pub const maximum_payload: usize = message_maximum - request_size;
/// Open flags (danos-native; `runtime.fs.OpenOptions` maps its booleans onto these). /// `open(flags)` with the path as the packet's tail. The one verb that spends a
/// path; everything after it addresses the node id this returns.
pub const Open = extern struct { flags: u32 = 0 };
/// The node id an `open` established the integer every later packet puts in
/// `Header.target`. Meaningful only between this client and this backend.
pub const Opened = extern struct { node: u64 };
/// `read(offset, len)` on `Header.target`; the bytes come back as the reply tail.
pub const Read = extern struct {
offset: u64,
len: u32,
_padding: u32 = 0,
};
/// `write(offset, len)` on `Header.target`, with the data as the packet's tail.
pub const Write = extern struct {
offset: u64,
len: u32,
_padding: u32 = 0,
};
/// How many bytes a `write` actually took it may be short.
pub const Written = extern struct { count: u32 };
/// `readdir(cursor)` on `Header.target`: one entry per call, cursor-advanced.
pub const Readdir = extern struct { cursor: u64 };
/// The contract, whole. Verbs number from `envelope.first_protocol_operation`
/// (16) in this order; the reserved verbs below it mean what they mean
/// everywhere. `readdir` stays a protocol verb rather than folding into the
/// reserved `enumerate`: it enumerates the children of one *node*, where
/// `enumerate` names a provider's targets.
pub const Protocol = envelope.Define(.{
.name = "vfs",
.version = 1,
.operations = &.{
.{ .name = "open", .request = Open, .reply = Opened },
.{ .name = "close" },
.{ .name = "read", .request = Read },
.{ .name = "write", .request = Write, .reply = Written },
.{ .name = "status", .reply = FileStatus },
.{ .name = "readdir", .request = Readdir, .reply = DirectoryEntry },
// The mount router's two verbs. Path routing lives in the kernel now
// (system/kernel/vfs.zig), so no backend implements either; they keep
// their numbers so the vocabulary stays the one docs/vfs-protocol.md
// describes.
.{ .name = "mount" }, // tail = the prefix, capability = the backend's endpoint
.{ .name = "unmount" }, // tail = the prefix
// Filesystem mutation, path-based: the path is the packet's tail.
.{ .name = "mkdir" },
.{ .name = "unlink" },
// rename: the tail is the old path, a single 0x00 separator, then the
// new path. Same-directory rename only.
.{ .name = "rename" },
// The registry's claim verb (P2): the name is the tail and the
// provider's endpoint rides the call as its capability. A file backend
// refuses it; only init implements it.
.{ .name = "bind" },
},
});
pub const Operation = Protocol.Operation;
/// What a backend sizes its buffers to the call floor, as every protocol does.
pub const message_maximum: usize = Protocol.message_maximum;
/// The most inline payload any request may carry: the floor less the header and
/// the widest fixed request part, so one bound serves every verb (a path, write
/// data, a read's answer).
pub const maximum_payload: usize = envelope.packet_maximum - Protocol.request_maximum;
/// Open flags (danos-native; `file_system.OpenOptions` maps its booleans onto these).
pub const create: u32 = 1; pub const create: u32 = 1;
/// Open a directory (for readdir) rather than a file. A mounted backend uses /// Open a directory (for readdir) rather than a file. A mounted backend uses
/// this to open a directory node; the flat ramfs ignores it. /// this to open a directory node; the flat ramfs ignores it.
@ -126,7 +152,7 @@ pub const directory: u32 = 2;
/// backend frees the old cluster chain; the flat ramfs ignores it. /// backend frees the old cluster chain; the flat ramfs ignores it.
pub const truncate: u32 = 4; pub const truncate: u32 = 4;
test "protocol struct sizes and node kinds" { test "the stable wire values: node kinds, entry layout, and the verb numbering" {
const std = @import("std"); const std = @import("std");
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular)); try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular));
try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory)); try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory));
@ -134,11 +160,34 @@ test "protocol struct sizes and node kinds" {
try std.testing.expectEqual(@as(u32, 6), @intFromEnum(NodeKind.socket)); try std.testing.expectEqual(@as(u32, 6), @intFromEnum(NodeKind.socket));
try std.testing.expectEqual(@as(u32, 7), @intFromEnum(NodeKind.protocol)); try std.testing.expectEqual(@as(u32, 7), @intFromEnum(NodeKind.protocol));
try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry)); try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry));
// The appended operations keep the original values.
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open)); // The numbering the envelope gives this protocol. These are NEW values: the
try std.testing.expectEqual(@as(u32, 4), @intFromEnum(Operation.status)); // rebase moved every verb above the reserved range, so the old 0..11 are
try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir)); // gone and 16..27 are what the wire carries. Pinned because both sides of a
try std.testing.expectEqual(@as(u32, 10), @intFromEnum(Operation.rename)); // flag-day have to agree on them, not because they may never change again.
// The registry's claim verb, appended last with the protocol namespace. try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.open));
try std.testing.expectEqual(@as(u32, 11), @intFromEnum(Operation.bind)); try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.close));
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.read));
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Operation.write));
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Operation.status));
try std.testing.expectEqual(@as(u32, 21), @intFromEnum(Operation.readdir));
try std.testing.expectEqual(@as(u32, 26), @intFromEnum(Operation.rename));
try std.testing.expectEqual(@as(u32, 27), @intFromEnum(Operation.bind));
// The payload bound is what it always was, arrived at the other way round:
// the header plus the widest fixed request part is 32 bytes of the floor.
try std.testing.expectEqual(@as(usize, 224), maximum_payload);
}
test "the node id rides the header, and a path rides the tail" {
const std = @import("std");
var buffer: [message_maximum]u8 = undefined;
const opening = Protocol.encodeRequest(.open, 0, .{ .flags = create }, "/a/b", &buffer).?;
try std.testing.expectEqual(@as(u32, create), Protocol.decodeRequest(.open, opening).?.flags);
try std.testing.expectEqualStrings("/a/b", Protocol.requestTail(.open, opening));
try std.testing.expectEqual(@as(u64, 0), envelope.headerOf(opening).?.target);
const reading = Protocol.encodeRequest(.read, 7, .{ .offset = 512, .len = 64 }, &.{}, &buffer).?;
try std.testing.expectEqual(@as(u64, 7), envelope.headerOf(reading).?.target);
try std.testing.expectEqual(@as(u64, 512), Protocol.decodeRequest(.read, reading).?.offset);
} }

View File

@ -252,8 +252,11 @@ pub const klog_maximum_message: usize = 256;
pub const fs_route_kernel: u64 = 0; // rdx = node token; serve via fs_node pub const fs_route_kernel: u64 = 0; // rdx = node token; serve via fs_node
pub const fs_route_backend: u64 = 1; // rdx = endpoint handle; speak vfs-protocol pub const fs_route_backend: u64 = 1; // rdx = endpoint handle; speak vfs-protocol
/// fs_node operations the same numbers as the vfs-protocol Operation enum, so /// fs_node operations. These were once the vfs-protocol Operation numbers; the
/// client code shares one vocabulary. /// rebase onto the envelope moved every protocol verb above the reserved range
/// (16 and up), and these did not follow they are a *syscall* selector, not a
/// packet's verb, and renumbering a kernel ABI to track a wire format would be
/// coupling in the wrong direction. The two vocabularies are simply separate now.
pub const fs_node_read: u64 = 2; pub const fs_node_read: u64 = 2;
pub const fs_node_status: u64 = 4; pub const fs_node_status: u64 = 4;
pub const fs_node_readdir: u64 = 5; pub const fs_node_readdir: u64 = 5;

View File

@ -148,6 +148,16 @@
/test/system/services/input-source, kernel, open, input /test/system/services/input-source, kernel, open, input
/test/system/services/input-test, kernel, open, input /test/system/services/input-test, kernel, open, input
# The conformance probe (test/system/services/protocol-conformance-test) asks
# every provider its boot bound for the envelope's reserved verbs. It reaches
# ONLY the two contracts its own scenario boots a provider for, named one at a
# time exactly like the two rows above — no subtree, no wildcard. Everything else
# under /protocol stays absent for it, which is the point: the fixture walks the
# namespace listing and reports what it could not open rather than being handed
# the tree to make the test look broad.
/test/system/services/protocol-conformance-test, kernel, open, input
/test/system/services/protocol-conformance-test, kernel, open, display
# The laundering-deputy probe (test/system/services/protocol-registry-test) runs # The laundering-deputy probe (test/system/services/protocol-registry-test) runs
# a grandchild whose supervisor is a fixture nobody authorized — that is the # a grandchild whose supervisor is a fixture nobody authorized — that is the
# point of it, and its bind must stay refused. It still has to report the verdict # point of it, and its bind must stay refused. It still has to report the verdict

Can't render this file because it contains an unexpected character in line 12 and column 15.

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "usb-storage", .name = "usb-storage",
.root_source_file = b.path("usb-storage.zig"), .root_source_file = b.path("usb-storage.zig"),
.imports = &.{ .imports = &.{
"block-protocol", "driver", "ipc", "logging", "memory", "process", "service", "block-protocol", "driver", "envelope", "ipc", "logging", "memory", "process",
"time", "usb", "service", "time", "usb",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -22,8 +22,16 @@ const logging = @import("logging");
const usb = @import("usb"); const usb = @import("usb");
const scsi = @import("scsi.zig"); const scsi = @import("scsi.zig");
const bot = @import("bulk-only-transport.zig"); const bot = @import("bulk-only-transport.zig");
const envelope = @import("envelope");
const block_protocol = @import("block-protocol"); const block_protocol = @import("block-protocol");
/// The generated block dispatch. One device per process, so the handler context
/// is empty and the geometry stays in this file's globals.
const Serve = block_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
var device_id: u64 = 0; var device_id: u64 = 0;
var device: usb.Device = undefined; var device: usb.Device = undefined;
var bulk_in: usb.Endpoint = undefined; var bulk_in: usb.Endpoint = undefined;
@ -144,53 +152,65 @@ fn initialise(endpoint: ipc.Handle) bool {
return true; return true;
} }
/// Serve the block protocol: geometry, and whole-block read/write to/from the // --- serving the block protocol ---------------------------------------------
/// caller's DMA buffer (named by physical address). //
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { // Geometry, and whole-block read/write to and from the caller's DMA buffer
_ = sender; // (named by physical address). One device per process, so `Header.target` is
if (message.len < block_protocol.request_size) return 0; // always 0 and no handler reads it.
const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]);
switch (request.operation) { /// A transfer the device refused. Every failure here is the same one the SCSI
@intFromEnum(block_protocol.Operation.attach) => { /// command did not complete so there is one errno for all of them.
// The filesystem's DMA buffer: forward its capability to the controller const refused: isize = -envelope.ENOENT;
// so the device can reach it. Never claimed the binding holds its own
// reference, so our copy is the turn's to close, on this path and on the fn onGeometry(_: void, _: Invocation(void), answer: Answer(block_protocol.Geometry)) isize {
// refusal above it alike. answer.set(.{ .block_size = block_size, .block_count = block_count });
const handle = arrived.peek() orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 }); return 0;
const ok = device.attachDma(handle);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = 0, .block_count = 0 });
},
@intFromEnum(block_protocol.Operation.geometry) => {
return writeReply(reply, .{ .status = 0, .block_size = block_size, .block_count = block_count });
},
@intFromEnum(block_protocol.Operation.read) => {
const count: u16 = @intCast(request.count);
const cdb = scsi.read10(@intCast(request.lba), count);
const ok = transact(&cdb, true, request.physical, request.count * block_size);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
},
@intFromEnum(block_protocol.Operation.write) => {
const count: u16 = @intCast(request.count);
const cdb = scsi.write10(@intCast(request.lba), count);
const ok = transact(&cdb, false, request.physical, request.count * block_size);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
},
@intFromEnum(block_protocol.Operation.flush) => {
// SYNCHRONIZE CACHE: commit the device's write cache to flash. No data
// stage. Makes prior writes durable before a caller (init at shutdown)
// cuts power. A device without a volatile cache reports success anyway.
const cdb = scsi.synchronizeCache10();
const ok = transact(&cdb, false, 0, 0);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = 0 });
},
else => return 0,
}
} }
fn writeReply(reply: []u8, value: block_protocol.Reply) usize { fn onRead(_: void, invocation: Invocation(block_protocol.Transfer), answer: Answer(block_protocol.Transferred)) isize {
const bytes = std.mem.asBytes(&value); const request = invocation.request;
@memcpy(reply[0..bytes.len], bytes); const cdb = scsi.read10(@intCast(request.lba), @intCast(request.count));
return bytes.len; if (!transact(&cdb, true, request.physical, request.count * block_size)) return refused;
answer.set(.{ .count = request.count });
return 0;
}
fn onWrite(_: void, invocation: Invocation(block_protocol.Transfer), answer: Answer(block_protocol.Transferred)) isize {
const request = invocation.request;
const cdb = scsi.write10(@intCast(request.lba), @intCast(request.count));
if (!transact(&cdb, false, request.physical, request.count * block_size)) return refused;
answer.set(.{ .count = request.count });
return 0;
}
/// SYNCHRONIZE CACHE: commit the device's write cache to flash. No data stage.
/// Makes prior writes durable before a caller (init at shutdown) cuts power. A
/// device without a volatile cache reports success anyway.
fn onFlush(_: void, _: Invocation(void), _: Answer(void)) isize {
const cdb = scsi.synchronizeCache10();
return if (transact(&cdb, false, 0, 0)) 0 else refused;
}
/// The filesystem's DMA buffer: forward its capability to the controller so the
/// device can reach it. Never claimed the binding holds its own reference, so
/// our copy is the turn's to close, on this path and on the refusal alike.
fn onAttach(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const handle = invocation.capability orelse return -envelope.EPROTO;
return if (device.attachDma(handle)) 0 else refused;
}
const handlers = Serve.Handlers{
.geometry = onGeometry,
.read = onRead,
.write = onWrite,
.flush = onFlush,
.attach = onAttach,
};
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
// Peeked, never taken: `attach` forwards the capability and the controller's
// binding takes its own reference, so this copy stays the turn's to close.
return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
} }
pub fn main(init: process.Init) void { pub fn main(init: process.Init) void {

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "virtio-gpu", .name = "virtio-gpu",
.root_source_file = b.path("virtio-gpu.zig"), .root_source_file = b.path("virtio-gpu.zig"),
.imports = &.{ .imports = &.{
"channel", "display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", "channel", "display-protocol", "driver", "envelope", "ipc", "logging", "memory",
"process", "scanout-protocol", "service", "time", "mmio", "pci", "process", "scanout-protocol", "service", "time",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -25,11 +25,19 @@ const memory = @import("memory");
const logging = @import("logging"); const logging = @import("logging");
const mmio = @import("mmio"); const mmio = @import("mmio");
const pci = @import("pci"); const pci = @import("pci");
const envelope = @import("envelope");
const display_protocol = @import("display-protocol"); const display_protocol = @import("display-protocol");
const scanout_protocol = @import("scanout-protocol"); const scanout_protocol = @import("scanout-protocol");
const vp = @import("virtio-pci.zig"); const vp = @import("virtio-pci.zig");
const vg = @import("virtio-gpu-protocol.zig"); const vg = @import("virtio-gpu-protocol.zig");
/// The generated scanout dispatch. One scanout per driver instance, so the
/// handler context is empty and the mode stays in this file's globals.
const Serve = scanout_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// The DisplayFormat (device-abi) our B8G8R8X8 scanout resource presents: bgrx = 1. Handed to /// The DisplayFormat (device-abi) our B8G8R8X8 scanout resource presents: bgrx = 1. Handed to
/// the compositor in the announce so it packs colours in the surface's byte order. /// the compositor in the announce so it packs colours in the surface's byte order.
const display_format_bgrx: u32 = 1; const display_format_bgrx: u32 = 1;
@ -482,60 +490,60 @@ fn announce() void {
std.log.info("no display service to announce to (scanout-only)", .{}); std.log.info("no display service to announce to (scanout-only)", .{});
return; return;
}; };
var request = display_protocol.Request{ var packet: [display_protocol.message_maximum]u8 = undefined;
.operation = @intFromEnum(display_protocol.Operation.attach_scanout), const framed = display_protocol.Protocol.encodeRequest(.attach_scanout, 0, .{
.x = max_width, // the shared surface's row stride in pixels (it is sized to the max mode) .stride = max_width, // the shared surface's row stride in pixels (it is sized to the max mode)
.y = edid_refresh_hz, // the panel refresh from EDID (0 = unknown) the frame-clock seed
.width = current_width, .width = current_width,
.height = current_height, .height = current_height,
.colour = display_format_bgrx, .format = display_format_bgrx,
}; .refresh_hz = edid_refresh_hz, // from EDID (0 = unknown) the frame-clock seed
var reply: [display_protocol.reply_size]u8 = undefined; }, &.{}, &packet) orelse return;
_ = ipc.callCap(display, std.mem.asBytes(&request), &reply, surface.handle) catch { var reply: [display_protocol.message_maximum]u8 = undefined;
_ = ipc.callCap(display, framed, &reply, surface.handle) catch {
std.log.info("announce to display failed", .{}); std.log.info("announce to display failed", .{});
return; return;
}; };
std.log.info("announced scanout to display", .{}); std.log.info("announced scanout to display", .{});
} }
/// A `scanout_protocol.Reply{status}` written into `reply`. // --- serving the scanout protocol -------------------------------------------
fn scanoutStatus(reply: []u8, ok: bool) usize { //
const response = scanout_protocol.Reply{ .status = if (ok) 0 else -1 }; // The compositor drives present / mode queries here. The pixels are already in
@memcpy(reply[0..scanout_protocol.reply_size], std.mem.asBytes(&response)); // the shared surface, so a present is a transfer-to-host + fenced flush; a mode
return scanout_protocol.reply_size; // change just re-points the scanout rectangle (the surface is sized to the
// largest mode). One scanout, so `Header.target` is always 0.
/// The device did not take the frame, or the mode asked for is not one this
/// scanout offers.
const refused: isize = -envelope.ENOENT;
fn onPresent(_: void, _: Invocation(scanout_protocol.Present), _: Answer(void)) isize {
return if (presentFull()) 0 else refused;
} }
/// The `.scanout` service: the compositor drives present / mode queries here. The pixels are fn onGetModes(_: void, _: Invocation(void), answer: Answer(scanout_protocol.Modes)) isize {
/// already in the shared surface, so a present is a transfer-to-host + fenced flush; a mode var offered = scanout_protocol.Modes{ .count = offered_modes.len };
/// change just re-points the scanout rectangle (the surface is sized to the largest mode). for (0..@min(offered_modes.len, scanout_protocol.max_modes)) |i| {
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { offered.modes[i] = .{ .width = offered_modes[i].width, .height = offered_modes[i].height };
_ = sender;
_ = arrived; // nothing here takes a capability: the harness closes what arrives
if (message.len < scanout_protocol.request_size) return 0;
const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]);
switch (request.operation) {
@intFromEnum(scanout_protocol.Operation.present) => return scanoutStatus(reply, presentFull()),
@intFromEnum(scanout_protocol.Operation.get_modes) => {
var response = scanout_protocol.ModesReply{ .status = 0, .count = offered_modes.len, .modes = undefined };
for (0..scanout_protocol.max_modes) |i| {
response.modes[i] = if (i < offered_modes.len)
.{ .width = offered_modes[i].width, .height = offered_modes[i].height }
else
.{ .width = 0, .height = 0 };
}
@memcpy(reply[0..scanout_protocol.modes_reply_size], std.mem.asBytes(&response));
return scanout_protocol.modes_reply_size;
},
@intFromEnum(scanout_protocol.Operation.set_mode) => {
const w = request.width;
const h = request.height;
if (w == 0 or h == 0 or w > max_width or h > max_height) return scanoutStatus(reply, false);
current_width = w;
current_height = h;
return scanoutStatus(reply, setScanoutRect());
},
else => return 0,
} }
answer.set(offered);
return 0;
}
fn onSetMode(_: void, invocation: Invocation(scanout_protocol.SetMode), _: Answer(void)) isize {
const w = invocation.request.width;
const h = invocation.request.height;
if (w == 0 or h == 0 or w > max_width or h > max_height) return refused;
current_width = w;
current_height = h;
return if (setScanoutRect()) 0 else refused;
}
const handlers = Serve.Handlers{ .present = onPresent, .get_modes = onGetModes, .set_mode = onSetMode };
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = arrived; // nothing here takes a capability: the harness closes what arrives
return Serve.dispatch({}, handlers, message, sender, null, reply);
} }
pub fn main(init: process.Init) void { pub fn main(init: process.Init) void {
@ -547,7 +555,7 @@ pub fn main(init: process.Init) void {
std.log.info("malformed device id '{s}'", .{argument}); std.log.info("malformed device id '{s}'", .{argument});
return; return;
}; };
service.run(256, .{ service.run(scanout_protocol.message_maximum, .{
.service = "scanout", .service = "scanout",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,

View File

@ -250,6 +250,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
protocolRegistryTest(boot_information); protocolRegistryTest(boot_information);
} else if (eql(case, "protocol-denied")) { } else if (eql(case, "protocol-denied")) {
protocolDeniedTest(boot_information); protocolDeniedTest(boot_information);
} else if (eql(case, "protocol-conformance")) {
protocolConformanceTest(boot_information);
} else if (eql(case, "reboot")) { } else if (eql(case, "reboot")) {
rebootTest(); rebootTest();
} else { } else {
@ -3898,6 +3900,68 @@ fn protocolDeniedTest(boot_information: *const BootInformation) void {
result(); result();
} }
/// P4a the reserved verbs, asked of live providers
/// (docs/security-track-plan.md P4a; docs/os-development/protocol-namespace.md).
/// Every protocol rebased onto `envelope.Define` gets `describe` answered from its
/// specification and `-ENOSYS` for a verb it does not define, without its provider
/// implementing either this case is where that stops being a host unit test of
/// the generated dispatch and becomes an observation of real providers over real
/// IPC.
///
/// The scenario is the assertion's scaffolding, the same shape `protocol-denied`
/// uses: `/protocol` (init in its registry role) plus the providers the fixture is
/// granted to reach the **input service** and the **compositor**, two protocols
/// of different sizes and different verb counts, so "uniform" means something. The
/// fixture reads `/protocol`'s own listing rather than a list compiled into it, so
/// what it checks is what this boot actually bound; the three other P4a protocols
/// (vfs, block, scanout) sit behind hardware chains this scenario deliberately does
/// not boot, and the fixture names them on serial as unchecked rather than passing
/// over them.
///
/// The fixture's `protocol-conformance: ok` is the marker; each contract it checks
/// prints its own line, which the harness's ordered regex reads.
fn protocolConformanceTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-conformance\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
// The two providers under test. Neither needs hardware beyond the framebuffer
// the kernel already seeded: input binds /protocol/input and waits for
// subscribers, and the compositor binds /protocol/display and composes into
// that framebuffer (the display-service scenario boots it exactly this way).
check("input service spawned", spawnNamed(rd, "input"));
check("display service spawned", spawnNamed(rd, "display"));
check("protocol-conformance-test spawned", spawnNamedWithArg(rd, "protocol-conformance-test", "run"));
const pass_marker = "protocol-conformance: ok";
const fail_marker = "protocol-conformance: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no provider failed the reserved-verb contract", !saw_fail);
check("the fixture conformance-checked every provider its scenario boots", saw_pass);
result();
}
fn deviceManagerTest(boot_information: *const BootInformation) void { fn deviceManagerTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-manager\n", .{}); log("DANOS-TEST-BEGIN: device-manager\n", .{});
if (boot_information.initial_ramdisk_len == 0) { if (boot_information.initial_ramdisk_len == 0) {

View File

@ -13,6 +13,7 @@ const memory = @import("memory");
const logging = @import("logging"); const logging = @import("logging");
const compositor = @import("compositor.zig"); const compositor = @import("compositor.zig");
const envelope = @import("envelope");
const scanout_protocol = @import("scanout-protocol"); const scanout_protocol = @import("scanout-protocol");
const Rect = compositor.Rect; const Rect = compositor.Rect;
const Surface = compositor.Surface; const Surface = compositor.Surface;
@ -188,45 +189,53 @@ pub const VirtioGpu = struct {
/// accumulated; the driver transfers + fenced-flushes the whole frame. /// accumulated; the driver transfers + fenced-flushes the whole frame.
pub fn present(self: *const VirtioGpu, damage: []const Rect) void { pub fn present(self: *const VirtioGpu, damage: []const Rect) void {
_ = damage; _ = damage;
var request = scanout_protocol.Request{ _ = call(self.scanout, .present, .{ .width = self.width, .height = self.height });
.operation = @intFromEnum(scanout_protocol.Operation.present),
.width = self.width,
.height = self.height,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
_ = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch {};
} }
/// Fill `out` with the driver's offered modes; returns how many were written. /// Fill `out` with the driver's offered modes; returns how many were written.
pub fn modes(self: *const VirtioGpu, out: []Mode) usize { pub fn modes(self: *const VirtioGpu, out: []Mode) usize {
var request = scanout_protocol.Request{ .operation = @intFromEnum(scanout_protocol.Operation.get_modes) }; const answered = call(self.scanout, .get_modes, {}) orelse return 0;
var reply: [scanout_protocol.modes_reply_size]u8 = undefined; const offered = Scanout.decodeReply(.get_modes, answered.packet[0..answered.len]) orelse return 0;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return 0; const count = @min(@min(offered.count, scanout_protocol.max_modes), out.len);
if (n < scanout_protocol.modes_reply_size) return 0; for (0..count) |i| out[i] = offered.modes[i];
const answer = std.mem.bytesToValue(scanout_protocol.ModesReply, reply[0..scanout_protocol.modes_reply_size]);
if (answer.status != 0) return 0;
const count = @min(@min(answer.count, scanout_protocol.max_modes), out.len);
for (0..count) |i| out[i] = answer.modes[i];
return count; return count;
} }
/// Change the scanout resolution. On success the active `width`/`height` update (the shared /// Change the scanout resolution. On success the active `width`/`height` update (the shared
/// surface sized to the max mode is unchanged, so `stride` stays put). /// surface sized to the max mode is unchanged, so `stride` stays put).
pub fn setMode(self: *VirtioGpu, w: u32, h: u32) bool { pub fn setMode(self: *VirtioGpu, w: u32, h: u32) bool {
if (w == 0 or h == 0 or w > self.stride) return false; if (w == 0 or h == 0 or w > self.stride) return false;
var request = scanout_protocol.Request{ _ = call(self.scanout, .set_mode, .{ .width = w, .height = h }) orelse return false;
.operation = @intFromEnum(scanout_protocol.Operation.set_mode),
.width = w,
.height = h,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return false;
if (n < scanout_protocol.reply_size) return false;
if (std.mem.bytesToValue(scanout_protocol.Reply, reply[0..scanout_protocol.reply_size]).status != 0) return false;
self.width = w; self.width = w;
self.height = h; self.height = h;
return true; return true;
} }
}; };
const Scanout = scanout_protocol.Protocol;
/// A reply the driver answered with, kept whole so the caller can decode the
/// verb's own fixed part out of it.
const Answered = struct {
packet: [scanout_protocol.message_maximum]u8,
len: usize,
};
/// One request at the scanout driver. Null covers both a transport failure and a
/// driver that refused a present that did not happen is a present that did not
/// happen, and this backend has nothing to do about either but skip the frame.
fn call(
scanout: ipc.Handle,
comptime operation: Scanout.Operation,
request: Scanout.RequestOf(operation),
) ?Answered {
var packet: [scanout_protocol.message_maximum]u8 = undefined;
const framed = Scanout.encodeRequest(operation, 0, request, &.{}, &packet) orelse return null;
var answered: Answered = .{ .packet = undefined, .len = 0 };
answered.len = ipc.call(scanout, framed, &answered.packet) catch return null;
const status = envelope.statusOf(answered.packet[0..answered.len]) orelse return null;
if (status.status != 0) return null;
return answered;
}
/// The pluggable scanout backend. A tagged union so the compositor holds one value and /// The pluggable scanout backend. A tagged union so the compositor holds one value and
/// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4. /// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4.
pub const Backend = union(enum) { pub const Backend = union(enum) {

View File

@ -10,8 +10,9 @@ pub fn build(b: *std.Build) void {
.name = "display", .name = "display",
.root_source_file = b.path("display.zig"), .root_source_file = b.path("display.zig"),
.imports = &.{ .imports = &.{
"channel", "display-client", "display-protocol", "driver", "input-client", "ipc", "channel", "display-client", "display-protocol", "driver", "envelope", "input-client",
"logging", "memory", "scanout-protocol", "service", "thread", "time", "ipc", "logging", "memory", "scanout-protocol", "service",
"thread", "time",
}, },
.threaded = true, // real atomics/TLS (docs/threading.md) .threaded = true, // real atomics/TLS (docs/threading.md)
}); });

View File

@ -28,10 +28,22 @@ const logging = @import("logging");
const compositor = @import("compositor.zig"); const compositor = @import("compositor.zig");
const backend_mod = @import("backend.zig"); const backend_mod = @import("backend.zig");
const envelope = @import("envelope");
const display_protocol = @import("display-protocol"); const display_protocol = @import("display-protocol");
const Rect = compositor.Rect; const Rect = compositor.Rect;
const Surface = compositor.Surface; const Surface = compositor.Surface;
/// The generated display dispatch. One compositor per process, so the handler
/// context is empty and the layer stack stays in this file's globals.
const Serve = display_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// What a handler returns when the layer named in `Header.target` is not one of
/// ours, or a mode was refused.
const refused: isize = -envelope.ENOENT;
/// The active scanout backend the GOP framebuffer at boot, upgraded to a native driver /// The active scanout backend the GOP framebuffer at boot, upgraded to a native driver
/// (virtio-gpu) when one announces itself (V4). /// (virtio-gpu) when one announces itself (V4).
var backend: backend_mod.Backend = undefined; var backend: backend_mod.Backend = undefined;
@ -314,11 +326,18 @@ fn verifyNativePresent() void {
/// `systemSharedMemoryMap`) the pixels stay ours after the handle naming them /// `systemSharedMemoryMap`) the pixels stay ours after the handle naming them
/// goes, and a driver that dies and re-announces no longer costs a handle slot /// goes, and a driver that dies and re-announces no longer costs a handle slot
/// per restart. /// per restart.
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, arrived: *ipc.Arrival, reply: []u8) usize { fn onAttachScanout(_: void, invocation: Invocation(display_protocol.AttachScanout), _: Answer(void)) isize {
const cap = arrived.peek() orelse return fail(reply); const announce = invocation.request;
if (width == 0 or height == 0 or stride < width) return fail(reply); const stride = announce.stride;
const mapped = memory.sharedMap(cap) orelse return fail(reply); const width = announce.width;
const scanout = channel.openEndpoint("scanout") orelse return fail(reply); const height = announce.height;
const format = announce.format;
const refresh_hz = announce.refresh_hz;
const cap = invocation.capability orelse return refused;
if (width == 0 or height == 0 or stride < width) return refused;
const mapped = memory.sharedMap(cap) orelse return refused;
const scanout = channel.openEndpoint("scanout") orelse return refused;
// A second announce means the driver died and was restarted (V6): re-attach to its fresh // A second announce means the driver died and was restarted (V6): re-attach to its fresh
// scanout. (The previous shared mapping leaks there is no shared_memory_unmap syscall yet but the // scanout. (The previous shared mapping leaks there is no shared_memory_unmap syscall yet but the
// frames are the dead driver's, reclaimed on its exit; a handful across a crash is benign.) // frames are the dead driver's, reclaimed on its exit; a handful across a crash is benign.)
@ -346,7 +365,7 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz:
"display: scanout re-attached\n" "display: scanout re-attached\n"
else else
"display: scanout upgraded to virtio-gpu\n"); "display: scanout upgraded to virtio-gpu\n");
return ok(reply); return 0;
} }
/// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present /// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present
@ -609,88 +628,109 @@ fn initialise(endpoint: ipc.Handle) bool {
return true; return true;
} }
fn writeReply(reply: []u8, value: display_protocol.Reply) usize { // --- the protocol handlers --------------------------------------------------
const bytes = std.mem.asBytes(&value); //
@memcpy(reply[0..bytes.len], bytes); // A layer id is `Header.target` on every verb that names one, so no handler
return bytes.len; // reads a layer out of its own request any more. `target` is a u64 and a layer
// id a u32: a value that does not fit is not a layer of ours, and `layerAt`
// refuses it the same way an out-of-range one is refused.
fn targetLayer(target: u64) ?u32 {
if (target > std.math.maxInt(u32)) return null;
return @intCast(target);
} }
fn ok(reply: []u8) usize { fn onInfo(_: void, _: Invocation(void), answer: Answer(display_protocol.Info)) isize {
return writeReply(reply, .{ .status = 0 }); const mode = backend.info();
answer.set(.{ .width = mode.width, .height = mode.height, .pitch = mode.pitch, .format = mode.format });
return 0;
} }
fn fail(reply: []u8) usize { fn onCreateLayer(_: void, invocation: Invocation(display_protocol.CreateLayer), answer: Answer(display_protocol.Created)) isize {
return writeReply(reply, .{ .status = -1 }); const request = invocation.request;
const slot = createLayer(request.x, request.y, request.width, request.height, request.z, request.visible != 0) orelse return refused;
answer.set(.{ .layer = slot });
return 0;
} }
fn onConfigureLayer(_: void, invocation: Invocation(display_protocol.ConfigureLayer), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
return if (configureLayer(id, request.x, request.y, request.z, request.visible != 0)) 0 else refused;
}
fn onDestroyLayer(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
return if (destroyLayer(id)) 0 else refused;
}
fn onFillRect(_: void, invocation: Invocation(display_protocol.FillRect), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
const local = Rect.init(request.x, request.y, @intCast(request.width), @intCast(request.height));
return if (fillLayer(id, local, request.colour)) 0 else refused;
}
fn onBlitTile(_: void, invocation: Invocation(display_protocol.BlitTile), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
return if (blitLayer(id, request.x, request.y, request.width, request.height, invocation.tail)) 0 else refused;
}
fn onDamage(_: void, invocation: Invocation(display_protocol.Damage), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const l = layerAt(id) orelse return refused;
const request = invocation.request;
const screen = Rect{ .x = l.x + request.x, .y = l.y + request.y, .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return 0;
}
fn onPresent(_: void, _: Invocation(void), _: Answer(void)) isize {
// Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return 0;
}
fn onSetMode(_: void, invocation: Invocation(display_protocol.SetMode), _: Answer(void)) isize {
if (!backend.setMode(invocation.request.width, invocation.request.height)) return refused;
addDamage(screenRect()); // repaint the whole screen at the new resolution
present();
return 0;
}
fn onGetModes(_: void, _: Invocation(void), answer: Answer(display_protocol.Modes)) isize {
var list: [4]backend_mod.Mode = undefined;
const count = backend.modes(&list);
var modes = display_protocol.Modes{ .count = @intCast(count) };
for (0..@min(count, display_protocol.max_modes)) |i| {
modes.modes[i] = .{ .width = list[i].width, .height = list[i].height };
}
answer.set(modes);
return 0;
}
const handlers = Serve.Handlers{
.info = onInfo,
.create_layer = onCreateLayer,
.configure_layer = onConfigureLayer,
.destroy_layer = onDestroyLayer,
.fill_rect = onFillRect,
.blit_tile = onBlitTile,
.damage = onDamage,
.present = onPresent,
.attach_scanout = onAttachScanout,
.set_mode = onSetMode,
.get_modes = onGetModes,
};
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = sender; // The one capability this service is ever handed is the scanout driver's
if (message.len < display_protocol.request_size) return fail(reply); // shared surface, and `attachScanout` deliberately does not claim it (the
const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]); // mapping holds its own reference) so the capability is peeked, never
const payload = message[display_protocol.request_size..]; // taken, and the turn closes it.
// Switch on the raw operation value an out-of-range one must fail cleanly, not return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
// panic an `@enumFromInt`.
switch (request.operation) {
@intFromEnum(display_protocol.Operation.info) => {
const m = backend.info();
return writeReply(reply, .{ .status = 0, .width = m.width, .height = m.height, .pitch = m.pitch, .format = m.format });
},
@intFromEnum(display_protocol.Operation.create_layer) => {
// x/y are signed coordinates carried in the u32 wire fields reinterpret the
// bits (@bitCast), don't range-check (@intCast) which a negative would fail.
const slot = createLayer(@bitCast(request.x), @bitCast(request.y), request.width, request.height, request.z, request.visible != 0) orelse return fail(reply);
return writeReply(reply, .{ .status = 0, .layer = slot });
},
@intFromEnum(display_protocol.Operation.configure_layer) => {
return if (configureLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.z, request.visible != 0)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.destroy_layer) => {
return if (destroyLayer(request.layer)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.fill_rect) => {
const local = Rect.init(@bitCast(request.x), @bitCast(request.y), @intCast(request.width), @intCast(request.height));
return if (fillLayer(request.layer, local, request.colour)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.blit_tile) => {
return if (blitLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.width, request.height, payload)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.damage) => {
const l = layerAt(request.layer) orelse return fail(reply);
const screen = Rect{ .x = l.x + @as(i32, @bitCast(request.x)), .y = l.y + @as(i32, @bitCast(request.y)), .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return ok(reply);
},
@intFromEnum(display_protocol.Operation.present) => {
// Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return ok(reply);
},
@intFromEnum(display_protocol.Operation.attach_scanout) => {
return attachScanout(request.x, request.width, request.height, request.colour, request.y, arrived, reply);
},
@intFromEnum(display_protocol.Operation.set_mode) => {
if (!backend.setMode(request.width, request.height)) return fail(reply);
addDamage(screenRect()); // repaint the whole screen at the new resolution
present();
return ok(reply);
},
@intFromEnum(display_protocol.Operation.get_modes) => {
var list: [4]backend_mod.Mode = undefined;
const count = backend.modes(&list);
var response = display_protocol.ModesReply{ .status = 0, .count = @intCast(count), .modes = undefined };
for (0..display_protocol.max_modes) |i| {
response.modes[i] = if (i < count)
.{ .width = list[i].width, .height = list[i].height }
else
.{ .width = 0, .height = 0 };
}
const bytes = std.mem.asBytes(&response);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
},
else => return fail(reply),
}
} }
/// Two notification sources reach the compositor, and one coalesced badge can carry /// Two notification sources reach the compositor, and one coalesced badge can carry

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "fat", .name = "fat",
.root_source_file = b.path("fat.zig"), .root_source_file = b.path("fat.zig"),
.imports = &.{ .imports = &.{
"block", "file-system", "ipc", "logging", "memory", "process", "service", "time", "block", "envelope", "file-system", "ipc", "logging", "memory", "process",
"vfs-protocol", "service", "time", "vfs-protocol",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -20,8 +20,17 @@ const memory = @import("memory");
const logging = @import("logging"); const logging = @import("logging");
const engine = @import("engine.zig"); const engine = @import("engine.zig");
const on_disk = @import("on-disk.zig"); const on_disk = @import("on-disk.zig");
const envelope = @import("envelope");
const vfs_protocol = @import("vfs-protocol"); const vfs_protocol = @import("vfs-protocol");
/// The generated vfs dispatch, bound to this server. There is one FAT volume per
/// process, so the handler context is empty and the state stays where it was: in
/// this file's globals.
const Serve = vfs_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
const mount_point = "/volumes/usb"; const mount_point = "/volumes/usb";
// The engine's BlockDevice, backed by the `.block` driver plus a DMA bounce // The engine's BlockDevice, backed by the `.block` driver plus a DMA bounce
@ -75,16 +84,11 @@ fn openAt(id: u64) ?*OpenNode {
return if (o.used) o else null; return if (o.used) o else null;
} }
fn writeReply(out: []u8, reply: vfs_protocol.Reply, payload: []const u8) usize { /// What a handler returns when the thing asked for is not there a bad node id,
@memcpy(out[0..vfs_protocol.reply_size], std.mem.asBytes(&reply)); /// a path that does not resolve, a mutation the volume refused. One errno for all
const n = @min(payload.len, out.len - vfs_protocol.reply_size); /// of them, because a filesystem's failures are all "no such thing" as far as the
@memcpy(out[vfs_protocol.reply_size..][0..n], payload[0..n]); /// file API can act on them.
return vfs_protocol.reply_size + n; const refused: isize = -envelope.ENOENT;
}
fn fail(out: []u8) usize {
return writeReply(out, .{ .status = -1 }, &.{});
}
/// How often to look for a block device while none is mounted. Storage arriving /// How often to look for a block device while none is mounted. Storage arriving
/// is EVENT-shaped (the usb chain registering, possibly after a driver restart), /// is EVENT-shaped (the usb chain registering, possibly after a driver restart),
@ -204,24 +208,128 @@ fn splitParent(path: []const u8) ParentLeaf {
}; };
} }
fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize { fn onOpen(_: void, invocation: Invocation(vfs_protocol.Open), answer: Answer(vfs_protocol.Opened)) isize {
const path = invocation.tail;
const flags = invocation.request.flags;
var node = filesystem.resolve(path); var node = filesystem.resolve(path);
if (node == null and flags & vfs_protocol.create != 0) { if (node == null and flags & vfs_protocol.create != 0) {
const split = splitParent(path); const split = splitParent(path);
const parent = filesystem.resolve(split.parent) orelse return fail(out); const parent = filesystem.resolve(split.parent) orelse return refused;
node = filesystem.createFile(parent, split.leaf); node = filesystem.createFile(parent, split.leaf);
} }
var resolved = node orelse return fail(out); var resolved = node orelse return refused;
// O_TRUNC: replace an existing file's contents rather than overwriting in place // O_TRUNC: replace an existing file's contents rather than overwriting in place
// (frees the old chain, so a shorter rewrite leaves no stale tail). // (frees the old chain, so a shorter rewrite leaves no stale tail).
if (flags & vfs_protocol.truncate != 0 and !resolved.is_directory) { if (flags & vfs_protocol.truncate != 0 and !resolved.is_directory) {
filesystem.truncate(&resolved); filesystem.truncate(&resolved);
} }
const index = allocOpen() orelse return fail(out); const index = allocOpen() orelse return refused;
open_nodes[index] = .{ .used = true, .node = resolved, .owner = sender }; open_nodes[index] = .{ .used = true, .node = resolved, .owner = invocation.sender };
return writeReply(out, .{ .status = 0, .node = index }, &.{}); answer.set(.{ .node = index });
return 0;
} }
fn onRead(_: void, invocation: Invocation(vfs_protocol.Read), answer: Answer(void)) isize {
const o = openAt(invocation.target) orelse return refused;
const into = answer.tail();
const want = @min(@as(usize, invocation.request.len), into.len);
return @intCast(filesystem.readFile(o.node, @intCast(invocation.request.offset), into[0..want]));
}
fn onWrite(_: void, invocation: Invocation(vfs_protocol.Write), answer: Answer(vfs_protocol.Written)) isize {
const o = openAt(invocation.target) orelse return refused;
const data = invocation.tail[0..@min(invocation.tail.len, invocation.request.len)];
const n = filesystem.writeFile(&o.node, @intCast(invocation.request.offset), data);
answer.set(.{ .count = @intCast(n) });
return 0;
}
fn onStatus(_: void, invocation: Invocation(void), answer: Answer(vfs_protocol.FileStatus)) isize {
const o = openAt(invocation.target) orelse return refused;
const kind: vfs_protocol.NodeKind = if (o.node.is_directory) .directory else .regular;
answer.set(.{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime });
return 0;
}
/// One entry per call. End of directory a node that is not a directory, or a
/// cursor past the last child is an entry with no name, which is how the
/// protocol spells it now that the reply's length always counts the fixed part.
fn onReaddir(_: void, invocation: Invocation(vfs_protocol.Readdir), answer: Answer(vfs_protocol.DirectoryEntry)) isize {
const o = openAt(invocation.target) orelse return refused;
if (!o.node.is_directory) {
answer.set(.{});
return 0;
}
const listing = filesystem.listEntry(o.node, @intCast(invocation.request.cursor)) orelse {
answer.set(.{});
return 0;
};
const kind: vfs_protocol.NodeKind = if (listing.is_directory) .directory else .regular;
const into = answer.tail();
const name_len = @min(listing.name_len, into.len);
@memcpy(into[0..name_len], listing.name_buffer[0..name_len]);
answer.set(.{ .kind = @intFromEnum(kind), .name_len = @intCast(name_len), .size = listing.size });
return @intCast(name_len);
}
fn onClose(_: void, invocation: Invocation(void), _: Answer(void)) isize {
if (openAt(invocation.target)) |o| o.used = false;
// Durable-on-close: if any block reached the device since the last flush,
// commit its cache to stable media now (best-effort). This is what makes
// init's shutdown log flush survive a real power-off, and is the right
// default for removable media the user may unplug.
if (device_dirty) {
_ = ipc_block.device.flush();
device_dirty = false;
}
return 0;
}
fn onMakeDirectory(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const path = invocation.tail;
if (filesystem.resolve(path) != null) return refused; // already exists no duplicate entries
const split = splitParent(path);
const parent = filesystem.resolve(split.parent) orelse return refused;
if (filesystem.createDirectory(parent, split.leaf) == null) return refused;
return 0;
}
fn onUnlink(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const split = splitParent(invocation.tail);
const parent = filesystem.resolve(split.parent) orelse return refused;
if (!filesystem.removeFile(parent, split.leaf)) return refused;
return 0;
}
fn onRename(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const both = invocation.tail;
const separator = std.mem.indexOfScalar(u8, both, 0) orelse return refused;
const old_split = splitParent(both[0..separator]);
const new_split = splitParent(both[separator + 1 ..]);
// Same-directory rename only.
if (!std.mem.eql(u8, old_split.parent, new_split.parent)) return refused;
const parent = filesystem.resolve(old_split.parent) orelse return refused;
if (!filesystem.rename(parent, old_split.leaf, new_split.leaf)) return refused;
return 0;
}
/// The verbs this backend implements. The three it leaves out `mount`,
/// `unmount`, `bind` answer `-ENOSYS` from the generated dispatch, which is
/// exactly right: path routing is the kernel's now, and only init implements
/// `bind` (docs/os-development/protocol-namespace.md). `describe` is the
/// envelope's own.
const handlers = Serve.Handlers{
.open = onOpen,
.close = onClose,
.read = onRead,
.write = onWrite,
.status = onStatus,
.readdir = onReaddir,
.mkdir = onMakeDirectory,
.unlink = onUnlink,
.rename = onRename,
};
/// The vfs protocol has no operation that takes a capability, so `arrived` is /// The vfs protocol has no operation that takes a capability, so `arrived` is
/// never claimed here which, under the harness's ownership rule, means the /// never claimed here which, under the harness's ownership rule, means the
/// loop closes whatever a caller attached. That is the point of the rule: this /// loop closes whatever a caller attached. That is the point of the rule: this
@ -230,92 +338,16 @@ fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize {
/// thirty-two until it could accept no capability at all. /// thirty-two until it could accept no capability at all.
fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize { fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = arrived; _ = arrived;
if (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry // Storage not up (yet): fail politely, whatever was asked clients retry.
if (message.len < vfs_protocol.request_size) return fail(out); if (!mounted) {
const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]); const status = envelope.Status{ .status = refused, .len = 0 };
const payload = message[vfs_protocol.request_size..]; @memcpy(out[0..envelope.prefix_size], std.mem.asBytes(&status));
return envelope.prefix_size;
}
// Stamp create/write with the current wall-clock time (mtime). Cheap, and it // Stamp create/write with the current wall-clock time (mtime). Cheap, and it
// keeps the engine pure (it takes the time as data, not a syscall). // keeps the engine pure (it takes the time as data, not a syscall).
filesystem.current_time_epoch = time.wallClock(); filesystem.current_time_epoch = time.wallClock();
return Serve.dispatch({}, handlers, message, sender, null, out);
switch (request.operation) {
.open => return handleOpen(out, payload[0..@min(payload.len, request.len)], request.flags, sender),
.read => {
const o = openAt(request.node) orelse return fail(out);
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
const want = @min(@as(usize, request.len), buffer.len);
const n = filesystem.readFile(o.node, @intCast(request.offset), buffer[0..want]);
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, buffer[0..n]);
},
.write => {
const o = openAt(request.node) orelse return fail(out);
const data = payload[0..@min(payload.len, request.len)];
const n = filesystem.writeFile(&o.node, @intCast(request.offset), data);
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, &.{});
},
.status => {
const o = openAt(request.node) orelse return fail(out);
const kind: vfs_protocol.NodeKind = if (o.node.is_directory) .directory else .regular;
const status = vfs_protocol.FileStatus{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime };
return writeReply(out, .{ .status = 0, .len = @sizeOf(vfs_protocol.FileStatus) }, std.mem.asBytes(&status));
},
.readdir => {
const o = openAt(request.node) orelse return fail(out);
if (!o.node.is_directory) return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
const listing = filesystem.listEntry(o.node, @intCast(request.offset)) orelse return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
const kind: vfs_protocol.NodeKind = if (listing.is_directory) .directory else .regular;
const header = vfs_protocol.DirectoryEntry{ .kind = @intFromEnum(kind), .name_len = @intCast(listing.name_len), .size = listing.size };
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
@memcpy(buffer[0..vfs_protocol.directory_entry_size], std.mem.asBytes(&header));
const nlen = @min(listing.name_len, buffer.len - vfs_protocol.directory_entry_size);
@memcpy(buffer[vfs_protocol.directory_entry_size..][0..nlen], listing.name_buffer[0..nlen]);
const total = vfs_protocol.directory_entry_size + nlen;
return writeReply(out, .{ .status = 0, .len = @intCast(total) }, buffer[0..total]);
},
.close => {
if (openAt(request.node)) |o| o.used = false;
// Durable-on-close: if any block reached the device since the last
// flush, commit its cache to stable media now (best-effort). This is
// what makes init's shutdown log flush survive a real power-off, and is
// the right default for removable media the user may unplug.
if (device_dirty) {
_ = ipc_block.device.flush();
device_dirty = false;
}
return writeReply(out, .{ .status = 0 }, &.{});
},
.mkdir => {
const path = payload[0..@min(payload.len, request.len)];
if (filesystem.resolve(path) != null) return fail(out); // already exists no duplicate entries
const split = splitParent(path);
const parent = filesystem.resolve(split.parent) orelse return fail(out);
if (filesystem.createDirectory(parent, split.leaf) == null) return fail(out);
return writeReply(out, .{ .status = 0 }, &.{});
},
.unlink => {
const split = splitParent(payload[0..@min(payload.len, request.len)]);
const parent = filesystem.resolve(split.parent) orelse return fail(out);
if (!filesystem.removeFile(parent, split.leaf)) return fail(out);
return writeReply(out, .{ .status = 0 }, &.{});
},
.rename => {
const both = payload[0..@min(payload.len, request.len)];
const sep = std.mem.indexOfScalar(u8, both, 0) orelse return fail(out);
const old_split = splitParent(both[0..sep]);
const new_split = splitParent(both[sep + 1 ..]);
// Same-directory rename only.
if (!std.mem.eql(u8, old_split.parent, new_split.parent)) return fail(out);
const parent = filesystem.resolve(old_split.parent) orelse return fail(out);
if (!filesystem.rename(parent, old_split.leaf, new_split.leaf)) return fail(out);
return writeReply(out, .{ .status = 0 }, &.{});
},
// A backend is never itself a mount target.
// Router verbs, and the registry's claim verb: a file backend answers
// none of them (docs/os-development/protocol-namespace.md only init
// implements `bind`).
.mount, .unmount, .bind => return fail(out),
}
} }
pub fn main() void { pub fn main() void {

View File

@ -548,34 +548,43 @@ var heartbeat_running = false;
/// `pending_capability`. `arrived` is the capability the *request* carried, owned /// `pending_capability`. `arrived` is the capability the *request* carried, owned
/// by the turn nothing here has to close it, only `bind` has to claim it. /// by the turn nothing here has to close it, only `bind` has to claim it.
fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *Arrival) usize { fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *Arrival) usize {
if (request_bytes.len < vfs_protocol.request_size) if (request_bytes.len < envelope.prefix_size)
return answer(reply, -envelope.EPROTO, 0, 0); return answer(reply, -envelope.EPROTO, 0);
// The header is read field by field rather than reinterpreted whole: the // The header is read field by field rather than reinterpreted whole, and the
// operation is an enum on the wire and the bytes come from anyone at all, so // verb is compared as a number rather than decoded into the generated
// a value outside it must be a refusal, never a decoded enum. // `Operation`: the bytes come from anyone at all, so a value outside the enum
// must be a refusal, never an `@enumFromInt`. This is deliberately NOT
// `Protocol.Provider.dispatch` for the same reason PID 1 reads a stranger's
// packet, and it reads it by hand.
const operation = std.mem.readInt(u32, request_bytes[0..4], .little); const operation = std.mem.readInt(u32, request_bytes[0..4], .little);
const cursor = std.mem.readInt(u64, request_bytes[16..24], .little); const body = request_bytes[envelope.prefix_size..];
const declared = std.mem.readInt(u32, request_bytes[24..28], .little);
const payload_len = @min(@as(usize, declared), request_bytes.len - vfs_protocol.request_size);
const payload = request_bytes[vfs_protocol.request_size..][0..payload_len];
if (operation == @intFromEnum(vfs_protocol.Operation.bind)) if (operation == @intFromEnum(vfs_protocol.Operation.bind))
return answer(reply, onBind(sender, payload, arrived), 0, 0); return answer(reply, onBind(sender, body, arrived), 0);
// Only `bind` claims a capability; one attached to anything else is closed by // Only `bind` claims a capability; one attached to anything else is closed by
// the turn's `defer` in the loop, along with the ones sent to a request that // the turn's `defer` in the loop, along with the ones sent to a request that
// was too short to name a verb at all. // was too short to name a verb at all.
if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, sender, payload); if (operation == @intFromEnum(vfs_protocol.Operation.open)) {
if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor); // `open`'s fixed part is the flags word, which means nothing to a
// namespace; the name follows it as the packet's tail.
if (body.len < @sizeOf(vfs_protocol.Open)) return answer(reply, -envelope.EPROTO, 0);
return onOpen(reply, sender, body[@sizeOf(vfs_protocol.Open)..]);
}
if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) {
if (body.len < @sizeOf(vfs_protocol.Readdir)) return answer(reply, -envelope.EPROTO, 0);
return onReaddir(reply, std.mem.readInt(u64, body[0..8], .little));
}
// Everything else a filesystem answers is meaningless here: `/protocol` holds // Everything else a filesystem answers is meaningless here: `/protocol` holds
// contracts, not bytes. // contracts, not bytes.
return answer(reply, -envelope.ENOSYS, 0, 0); return answer(reply, -envelope.ENOSYS, 0);
} }
/// Lay down a vfs reply header (and say how many payload bytes follow it). /// Lay down the envelope's reply prefix (and say how many payload bytes the
fn answer(reply: []u8, status: i32, node: u64, payload_len: usize) usize { /// caller has already written after it).
const header = vfs_protocol.Reply{ .status = status, .node = node, .len = @intCast(payload_len) }; fn answer(reply: []u8, status: i32, payload_len: usize) usize {
@memcpy(reply[0..vfs_protocol.reply_size], std.mem.asBytes(&header)); const header = envelope.Status{ .status = status, .len = @intCast(payload_len) };
return vfs_protocol.reply_size + payload_len; @memcpy(reply[0..envelope.prefix_size], std.mem.asBytes(&header));
return envelope.prefix_size + payload_len;
} }
/// `bind(name, capability = the provider's endpoint)`. The capability is the /// `bind(name, capability = the provider's endpoint)`. The capability is the
@ -667,15 +676,19 @@ fn onBind(sender: u32, raw_name: []const u8, arrived: *Arrival) i32 {
/// other, a line in a world-readable log ring, or a serial write costing /// other, a line in a world-readable log ring, or a serial write costing
/// milliseconds.) /// milliseconds.)
fn onOpen(reply: []u8, sender: u32, raw_name: []const u8) usize { fn onOpen(reply: []u8, sender: u32, raw_name: []const u8) usize {
const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0, 0); const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0);
refreshProcessTable(); refreshProcessTable();
const identity = identify(sender); const identity = identify(sender);
const permitted = if (identity) |who| mayOpen(who, name) else false; const permitted = if (identity) |who| mayOpen(who, name) else false;
const binding = findBinding(name); const binding = findBinding(name);
if (!permitted) return answer(reply, -envelope.ENOENT, 0, 0); if (!permitted) return answer(reply, -envelope.ENOENT, 0);
const found = binding orelse return answer(reply, -envelope.ENOENT, 0, 0); const found = binding orelse return answer(reply, -envelope.ENOENT, 0);
pending_capability = found.endpoint; pending_capability = found.endpoint;
return answer(reply, 0, 0, 0); // A contract node has no node id the capability is the whole answer but
// the protocol says an `open` reply carries one, so it carries a zero.
const opened = vfs_protocol.Opened{ .node = 0 };
@memcpy(reply[envelope.prefix_size..][0..@sizeOf(vfs_protocol.Opened)], std.mem.asBytes(&opened));
return answer(reply, 0, @sizeOf(vfs_protocol.Opened));
} }
/// `readdir(cursor)` the namespace, browsable. One entry per turn, as the vfs /// `readdir(cursor)` the namespace, browsable. One entry per turn, as the vfs
@ -690,18 +703,25 @@ fn onReaddir(reply: []u8, cursor: u64) usize {
continue; continue;
} }
const name = binding.nameSlice(); const name = binding.nameSlice();
const entry = vfs_protocol.DirectoryEntry{ return writeEntry(reply, .{
.kind = @intFromEnum(vfs_protocol.NodeKind.protocol), .kind = @intFromEnum(vfs_protocol.NodeKind.protocol),
.name_len = @intCast(name.len), .name_len = @intCast(name.len),
.size = binding.task, .size = binding.task,
}; }, name);
const total = vfs_protocol.directory_entry_size + name.len;
if (vfs_protocol.reply_size + total > reply.len) return answer(reply, -envelope.EPROTO, 0, 0);
@memcpy(reply[vfs_protocol.reply_size..][0..vfs_protocol.directory_entry_size], std.mem.asBytes(&entry));
@memcpy(reply[vfs_protocol.reply_size + vfs_protocol.directory_entry_size ..][0..name.len], name);
return answer(reply, 0, 0, total);
} }
return answer(reply, 0, 0, 0); // end of directory // End of directory, which the envelope spells as an entry with no name: the
// reply's own length cannot say it any more, because the fixed reply part
// always travels.
return writeEntry(reply, .{}, &.{});
}
/// One `readdir` reply: the entry, then its name inline.
fn writeEntry(reply: []u8, entry: vfs_protocol.DirectoryEntry, name: []const u8) usize {
const total = vfs_protocol.directory_entry_size + name.len;
if (envelope.prefix_size + total > reply.len) return answer(reply, -envelope.EPROTO, 0);
@memcpy(reply[envelope.prefix_size..][0..vfs_protocol.directory_entry_size], std.mem.asBytes(&entry));
@memcpy(reply[envelope.prefix_size + vfs_protocol.directory_entry_size ..][0..name.len], name);
return answer(reply, 0, total);
} }
pub fn main(startup: process.Init) void { pub fn main(startup: process.Init) void {

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "input", .name = "input",
.root_source_file = b.path("input.zig"), .root_source_file = b.path("input.zig"),
.imports = &.{ "channel", "input-client", "input-protocol", "ipc", "logging", "process", "service" }, .imports = &.{ "envelope", "input-protocol", "ipc", "logging", "process", "service" },
}); });
b.installArtifact(exe); b.installArtifact(exe);
} }

View File

@ -17,17 +17,29 @@
//! //!
//! A subscriber registers by handing the service its own endpoint as a capability (M13 //! A subscriber registers by handing the service its own endpoint as a capability (M13
//! capability passing this service is its first real consumer). The service keeps that //! capability passing this service is its first real consumer). The service keeps that
//! handle and `ipc.send`s each event to it. //! handle and `ipc.send`s each event to it. That is the envelope's reserved `subscribe`
//! verb, which this protocol adopts rather than defining its own.
//!
//! P4a moved this service onto the shared harness (library/kernel/service.zig). It was the
//! last hand-rolled receive loop in the tree, and the one service that answered neither the
//! universal ping nor a `terminate` signal so a shutdown had to kill it. The subscriber
//! table, the fan-out, and the prune-on-subscribe below are unchanged; lifting *those* into
//! the harness is a later milestone, and doing it here would have hidden this one.
const std = @import("std"); const envelope = @import("envelope");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); const service = @import("service");
const input = @import("input-client");
const logging = @import("logging"); const logging = @import("logging");
const input_protocol = @import("input-protocol"); const input_protocol = @import("input-protocol");
/// The generated input dispatch. One fan-out point per process, so the handler
/// context is empty and the subscriber table stays in this file's globals.
const Serve = input_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// One registered subscriber: the endpoint we push events to (a capability it handed us at /// One registered subscriber: the endpoint we push events to (a capability it handed us at
/// subscribe time) and the task id that owns it (the subscribe call's badge), so a slot /// subscribe time) and the task id that owns it (the subscribe call's badge), so a slot
/// left behind by a subscriber that exited can be reclaimed. /// left behind by a subscriber that exited can be reclaimed.
@ -83,80 +95,72 @@ fn addSubscriber(endpoint: ipc.Handle, task_id: u32, device_mask: u32) bool {
/// Push `event` to every subscriber whose interest mask includes its device class. /// Push `event` to every subscriber whose interest mask includes its device class.
/// `ipc.send` never blocks, so a slow or dead subscriber cannot stall delivery to others. /// `ipc.send` never blocks, so a slow or dead subscriber cannot stall delivery to others.
///
/// The class is the packet's operation, so the fan-out picks the event by device and the
/// packet is framed once, outside the loop every subscriber of a class gets identical
/// bytes, which is what "one fan-out point per event domain" means on the wire.
fn broadcast(event: input_protocol.InputEvent) void { fn broadcast(event: input_protocol.InputEvent) void {
const bytes = std.mem.asBytes(&event); const class = input_protocol.eventOfDevice(event.device) orelse return; // no class wants it
var packet: [envelope.post_maximum]u8 = undefined;
const framed = switch (class) {
.keyboard => input_protocol.Protocol.encodeEvent(.keyboard, 0, event.asKeyboard() orelse return, &packet),
.mouse => input_protocol.Protocol.encodeEvent(.mouse, 0, event.asMouse() orelse return, &packet),
.joystick => input_protocol.Protocol.encodeEvent(.joystick, 0, event.asJoystick() orelse return, &packet),
} orelse return;
const bit = input_protocol.deviceBit(event.device); const bit = input_protocol.deviceBit(event.device);
for (&subscribers) |*sub| { for (&subscribers) |*sub| {
if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, bytes); if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, framed);
} }
} }
/// Handle one request. `got` carries the sender badge (a task id); `arrived` carries the /// Set by `onSubscribe` when the subscriber table has taken ownership of the capability the
/// capability the request came with, under the same ownership rule the service harness /// call carried, and read by `onMessage`, which is where the turn's `Arrival` lives. The
/// states (`ipc.Arrival`): **it belongs to the turn, and only a handler that means to keep /// generated dispatch hands a handler the raw handle rather than the `Arrival` deliberately,
/// it says `take`.** Everything else here a short message, a `publish`, a subscribe that /// since a handler has no business closing the turn's property so the *claim* has to travel
/// finds the table full simply returns, and the loop closes what arrived. Writes a /// back out this way. One turn, one handler, one thread: there is nothing here to race.
/// `Reply` into `out` and returns its length. var capability_claimed = false;
fn handle(message: []const u8, got: ipc.Received, out: []u8, arrived: *ipc.Arrival) usize {
const reply = struct {
fn write(buffer: []u8, status: i32) usize {
const header = input_protocol.Reply{ .status = status };
@memcpy(buffer[0..input_protocol.reply_size], std.mem.asBytes(&header));
return input_protocol.reply_size;
}
};
if (message.len < input_protocol.request_size) return reply.write(out, -1); /// The reserved `subscribe` verb: register the caller's endpoint (the call's capability) for
const request = std.mem.bytesToValue(input_protocol.Request, message[0..input_protocol.request_size]); /// the classes in the packet's tail. Refusals simply return, and the turn closes what arrived
/// the ownership rule the harness states (`ipc.Arrival`), unchanged by the move onto it.
fn onSubscribe(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed
// A zero mask means "everything" (a subscriber that named no class still wants input).
const requested = input_protocol.decodeSubscribe(invocation.tail).device_mask;
const mask = if (requested == 0) input_protocol.device_all else requested;
pruneDeadSubscribers();
if (!addSubscriber(endpoint, invocation.sender, mask)) return -envelope.ENOSPC; // table full
capability_claimed = true; // the subscriber table holds it until that task dies
return 0;
}
switch (@as(input_protocol.Operation, @enumFromInt(request.operation))) { fn onPublish(_: void, invocation: Invocation(input_protocol.InputEvent), _: Answer(void)) isize {
.subscribe => { broadcast(invocation.request);
const endpoint = arrived.peek() orelse return reply.write(out, -1); // no endpoint passed return 0;
// A zero mask means "everything" (a subscriber that named no class still wants input). }
const mask = if (request.device_mask == 0) input_protocol.device_all else request.device_mask;
pruneDeadSubscribers(); const handlers = Serve.Handlers{ .publish = onPublish, .subscribe = onSubscribe };
if (!addSubscriber(endpoint, @intCast(got.badge), mask)) return reply.write(out, -1); // table full
_ = arrived.take(); // claimed: the subscriber table holds it until that task dies fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
return reply.write(out, 0); capability_claimed = false;
}, const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), out);
.publish => { if (capability_claimed) _ = arrived.take();
broadcast(request.event); return written;
return reply.write(out, 0); }
},
} fn initialise(_: ipc.Handle) bool {
// The harness has already bound `/protocol/input` by the time this runs, so
// "ready" still means what it always meant: the name is claimed and the loop
// is about to serve it.
_ = logging.write("/system/services/input: ready\n");
return true;
} }
pub fn main() void { pub fn main() void {
const endpoint = ipc.createIpcEndpoint() orelse { service.run(input_protocol.message_maximum, .{
_ = logging.write("/system/services/input: no endpoint\n"); .service = "input",
return; .init = initialise,
}; .on_message = onMessage,
if (!channel.bindPatiently("input", endpoint)) { });
_ = logging.write("/system/services/input: could not bind /protocol/input\n");
return;
}
_ = logging.write("/system/services/input: ready\n");
var reply_buffer: [input_protocol.reply_size]u8 = undefined;
var reply_len: usize = 0;
var receive: [input_protocol.request_size]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
// Whatever capability came with this turn is the turn's, and the turn closes it
// unless `handle` claims it (`ipc.Arrival`). The kernel installs a sent capability
// whatever the message's length or kind, so this covers the notification
// `continue` and every refusal inside `handle` otherwise about thirty-two
// capability-carrying calls, which need no authorization at all, exhaust this
// service's handle table and no further subscribe can ever land.
var arrived: ipc.Arrival = .{ .handle = got.cap };
defer arrived.release();
// Only synchronous client requests (subscribe/publish) arrive here; nothing sends
// this service asynchronous messages, so a notification wake would be spurious.
if (got.isNotification()) {
reply_len = 0;
continue;
}
reply_len = handle(receive[0..got.len], got, &reply_buffer, &arrived);
}
} }

View File

@ -889,6 +889,25 @@ CASES = [
r"(?=.*protocol-denied: ok)" r"(?=.*protocol-denied: ok)"
r"(?=.*DANOS-TEST-RESULT: PASS)", r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-denied: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL|protocol-denied: FAIL"},
# The reserved verbs, asked of live providers (P4a). Every protocol built on
# envelope.Define answers `describe` out of its specification and `-ENOSYS`
# for a verb it does not define, without its provider implementing either —
# so a fixture that walks /protocol's own listing and asks both of whatever
# it finds is the proof that `Define` hands those verbs to everyone alike.
# The scenario boots the registry, the input service and the compositor: two
# protocols of different sizes and verb counts, both reached through the real
# registry under the manifest's own grants. vfs, block and scanout are the
# P4a protocols whose providers need hardware chains this case does not boot;
# the fixture names them as unchecked rather than skipping them quietly, and
# the existing fat-mount / usb-storage / virtio-gpu scenarios are their proof.
{"name": "protocol-conformance",
"expect": r"(?s)(?=.*protocol-conformance: input v\d+ describes itself)"
r"(?=.*protocol-conformance: display v\d+ describes itself)"
r"(?=.*-> -ENOSYS)"
r"(?=.*protocol-conformance: 2 provider\(s\) answered the reserved verbs identically)"
r"(?=.*protocol-conformance: ok)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-conformance: FAIL"},
# Device manager: a ring-3 service enumerates /system/devices, matches the PCI host # Device manager: a ring-3 service enumerates /system/devices, matches the PCI host
# bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn
# -> driver-up (the spawned pci-bus logs "<N> functions found"). # -> driver-up (the spawned pci-bus logs "<N> functions found").

View File

@ -0,0 +1,28 @@
//! The protocol-conformance-test fixture as a binary package (docs/build-packages-plan.md):
//! this file names the binary and EXACTLY the modules its source imports
//! build-support resolves each name from the domains this zon declares.
const std = @import("std");
const build_support = @import("build-support");
pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "protocol-conformance-test",
.root_source_file = b.path("protocol-conformance-test.zig"),
.imports = &.{
"block-protocol",
"channel",
"display-protocol",
"envelope",
"file-system",
"input-protocol",
"ipc",
"logging",
"process",
"scanout-protocol",
"time",
"vfs-protocol",
},
});
b.installArtifact(exe);
}

View File

@ -0,0 +1,20 @@
.{
.name = .protocol_conformance_test,
.version = "0.0.0",
.fingerprint = 0xea942427118ba350, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// build-support supplies the shared recipe; kernel is implicit in
// every binary (the root shim + link script live there). The rest
// are exactly the homes of this binary's declared imports.
.@"build-support" = .{ .path = "../../../../build-support" },
.kernel = .{ .path = "../../../../library/kernel" },
// envelope: the reserved verbs and the errno a refused one answers;
// vfs-protocol: the registry's own readdir, which is where the set
// under test comes from; the four -protocol modules: the name, the
// version and the verb count each contract's `describe` must report,
// read off the contract itself rather than copied beside it.
.protocol = .{ .path = "../../../../library/protocol" },
},
.paths = .{""},
}

View File

@ -0,0 +1,332 @@
//! protocol-conformance-test P4a's evidence that `envelope.Define` gives every
//! provider the reserved verbs, uniformly and without the provider writing a line
//! for them (docs/security-track-plan.md P4a;
//! docs/os-development/protocol-namespace.md). One binary, one role, driven by
//! the `protocol-conformance` kernel case:
//!
//! - `protocol-conformance-test run` for each contract it can reach:
//! 1. `describe` the reserved verb 0 is answered, and the answer names
//! *that* protocol: the name it was opened under, the version its module
//! declares, and the number of verbs its module declares. No provider in
//! the system implements `describe`; the generated dispatch answers it out
//! of the specification, which is exactly the claim being checked;
//! 2. a verb number no protocol in the system defines answers `-ENOSYS`, and
//! carries no capability. That is the other half of the same generated
//! dispatch: a provider does not have to reject strangers, it gets the
//! rejection for free and every provider gives the same one;
//! 3. `describe` again, after the refusal a refused verb is an *answer*,
//! not a wedged service, so the channel is still good afterwards.
//!
//! **The set it checks is read, never hardcoded.** The fixture asks `/protocol`
//! for its own listing (`readdir`, which the namespace publishes on purpose) and
//! walks what it finds, so the case cannot drift from what this boot actually
//! bound. What it opens is bounded by P3: the manifest names this binary against
//! exactly the two contracts its scenario boots, and an ungranted name is absent
//! for it like any other client's.
//!
//! **What it covers, and what it cannot the honest list at P4a.** The scenario
//! boots the registry, the input service, and the compositor, so `input` and
//! `display` are checked end to end over real IPC. The other three protocols P4a
//! rebased are not asked here, and the reason is the provider, not the protocol:
//!
//! - `vfs` the FAT server, which needs a mounted volume behind the whole USB
//! storage chain (the `fat-mount` scenario);
//! - `block` the usb-storage driver, which the device manager spawns after
//! enumerating an xHCI bus (the `usb-storage` scenario);
//! - `scanout` the virtio-gpu driver, which needs an emulated virtio-gpu the
//! default harness does not attach (the `virtio-gpu` scenario).
//!
//! Booting any of those chains here would buy conformance for a third and fourth
//! provider at the price of a case that boots half the system to send two
//! packets; their rebase is proven instead by the scenarios that already drive
//! them. All three sit in the table below anyway, so if a future scenario binds
//! one, this fixture checks it without being edited and prints, every run, the
//! ones it found no provider for.
//!
//! **And the ones it must not ask.** `device-manager`, `power` and
//! `usb-transfer` are still hand-numbered (P4b): to them, operation 0 is a verb
//! of their own, not `describe`. So the table is not "every contract" but "every
//! contract already built on `Define`" — anything listed that is not in it is
//! reported as skipped by name, never silently. P4b adds three rows here and the
//! coverage follows.
//!
//! The registry itself PID 1 serving `/protocol` is the one vfs backend
//! deliberately NOT dispatched through the generated table (it reads a
//! stranger's packet by hand, `system/services/init/init.zig`), so `describe` is
//! not asked of it and nothing here claims it.
//!
//! Prints `protocol-conformance: ok` on success, or a `protocol-conformance:
//! FAIL` line naming the step. Spawned bare (the initial-ramdisk sweep starts
//! every bundled binary), it exits silently so it cannot derange other tests.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const file_system = @import("file-system");
const ipc = @import("ipc");
const logging = @import("logging");
const process = @import("process");
const time = @import("time");
const block_protocol = @import("block-protocol");
const display_protocol = @import("display-protocol");
const input_protocol = @import("input-protocol");
const scanout_protocol = @import("scanout-protocol");
const vfs_protocol = @import("vfs-protocol");
// --- what conformance means, per contract -----------------------------------
/// One contract this fixture knows how to check, and what the answer must say.
/// Every field is read off the protocol module itself, so the expectation is the
/// contract's own definition rather than a number copied beside it a version
/// bump or a new verb updates this table by recompiling.
const Contract = struct {
name: []const u8,
version: u32,
/// How many verbs the module declares `describe` reports it, so it is
/// checked. The reserved verbs are not counted: they are the envelope's.
operations: u32,
/// Whether this scenario boots a provider for it. A required contract that
/// is missing, unreachable or non-conforming fails the case; the rest are
/// checked when some other scenario happens to bind them.
required: bool,
};
fn contractOf(comptime Protocol: type, required: bool) Contract {
return .{
.name = Protocol.protocol_name,
.version = Protocol.version,
.operations = @typeInfo(Protocol.Operation).@"enum".fields.len,
.required = required,
};
}
/// The protocols built on `envelope.Define`, and nothing else. A name listed by
/// `/protocol` that is absent from here is reported and left alone see the
/// header: asking a hand-numbered provider for operation 0 would name one of its
/// own verbs.
const contracts = [_]Contract{
contractOf(input_protocol.Protocol, true), // the input fan-out service
contractOf(display_protocol.Protocol, true), // the compositor
contractOf(vfs_protocol.Protocol, false), // the FAT server needs a volume
contractOf(block_protocol.Protocol, false), // usb-storage needs the xHCI chain
contractOf(scanout_protocol.Protocol, false), // virtio-gpu needs the device
};
/// A verb number no protocol in the system defines, and none plausibly will: far
/// above the reserved range, so it is unambiguously a protocol verb, and far
/// above any protocol's verb count, so the generated dispatch has nothing to
/// match it against. The answer must be `-ENOSYS` at every provider.
const stranger_operation: u32 = envelope.first_protocol_operation + 4096;
fn fail(step: []const u8) noreturn {
_ = logging.write("protocol-conformance: FAIL ");
_ = logging.write(step);
_ = logging.write("\n");
process.exit(1);
}
fn report(comptime format: []const u8, arguments: anytype) void {
var line: [192]u8 = undefined;
_ = logging.write(std.fmt.bufPrint(&line, format, arguments) catch return);
}
// --- reading the namespace ---------------------------------------------------
/// The cadence every client in the tree spends finding a service.
const resolve_attempts: u32 = 200;
const resolve_retry_ms: u64 = 20;
/// The registry's endpoint, obtained the way every process obtains it: resolve
/// `/protocol`. The handle is the kernel's, shared with every other user of the
/// mount, so it is never ours to close. Patient, because the harness starts the
/// registrar and this fixture together and a first resolve can land before init
/// has mounted `/protocol` at all.
fn registryEndpoint() ?ipc.Handle {
var attempt: u32 = 0;
while (attempt < resolve_attempts) : (attempt += 1) {
var relative: [channel.path_maximum]u8 = undefined;
if (file_system.fsResolve(channel.root, 0, &relative)) |route| switch (route) {
.kernel => return null, // a kernel route means something other than the registry owns the name
.backend => |backend| return backend.handle,
};
time.sleepMillis(resolve_retry_ms);
}
return null;
}
/// One `readdir(cursor)` at the registry, into `into`. Null at end of directory
/// or on any failure the caller is walking a listing, and both mean "stop".
///
/// The listing is what makes this test un-driftable: `/protocol` publishes what
/// is bound (protocol-namespace.md the tree stays diagnosable), so the set
/// under test is the set this boot actually produced.
fn entryAt(registry: ipc.Handle, cursor: u64, into: []u8) ?[]u8 {
var packet: [vfs_protocol.message_maximum]u8 = undefined;
const framed = vfs_protocol.Protocol.encodeRequest(.readdir, 0, .{ .cursor = cursor }, &.{}, &packet) orelse return null;
var reply: [vfs_protocol.message_maximum]u8 = undefined;
const got = ipc.callCap(registry, framed, &reply, null) catch return null;
// A readdir owes no capability; one that arrived anyway is a handle slot.
if (got.cap) |handle| _ = ipc.close(handle);
const answer = reply[0..got.len];
const status = envelope.statusOf(answer) orelse return null;
if (status.status != 0) return null;
const entry = vfs_protocol.Protocol.decodeReply(.readdir, answer) orelse return null;
if (entry.name_len == 0) return null; // end of directory
const text = vfs_protocol.Protocol.replyTail(.readdir, answer);
const length = @min(@as(usize, entry.name_len), @min(text.len, into.len));
@memcpy(into[0..length], text[0..length]);
return into[0..length];
}
/// The listing, taken once so every later question is asked of one observation
/// rather than of a namespace that may have moved underneath it.
const maximum_listed: usize = 32;
var listed_names: [maximum_listed][channel.name_maximum]u8 = undefined;
var listed_lengths: [maximum_listed]usize = undefined;
var listed_count: usize = 0;
fn listedName(index: usize) []const u8 {
return listed_names[index][0..listed_lengths[index]];
}
fn takeListing(registry: ipc.Handle) void {
listed_count = 0;
var cursor: u64 = 0;
while (cursor < maximum_listed) : (cursor += 1) {
const name = entryAt(registry, cursor, &listed_names[listed_count]) orelse return;
listed_lengths[listed_count] = name.len;
listed_count += 1;
}
}
/// Whether `/protocol` currently lists `name`.
fn listed(registry: ipc.Handle, name: []const u8) bool {
var cursor: u64 = 0;
while (cursor < maximum_listed) : (cursor += 1) {
var scratch: [channel.name_maximum]u8 = undefined;
const entry = entryAt(registry, cursor, &scratch) orelse return false;
if (std.mem.eql(u8, entry, name)) return true;
}
return false;
}
/// Wait until `/protocol` lists `name` the providers this case needs come up
/// alongside the fixture, and racing them would make the listing a boot race
/// rather than an observation.
fn awaitListed(registry: ipc.Handle, name: []const u8) void {
var attempts: u32 = 0;
while (attempts < 400) : (attempts += 1) {
if (listed(registry, name)) return;
time.sleepMillis(20);
}
report("protocol-conformance: FAIL /protocol never listed {s}\n", .{name});
process.exit(1);
}
// --- the assertions ----------------------------------------------------------
/// The three checks, against one open channel. Every failure is fatal: the point
/// of the case is that these hold at *every* provider, so one that does not is
/// not a degraded result, it is the regression.
fn conform(link: channel.Channel, contract: Contract) void {
var buffer: [envelope.packet_maximum]u8 = undefined;
// 1. The reserved verb no provider implements. `describe` is answered from
// the specification by the generated dispatch, so what comes back is the
// contract's own identity checked field by field against the module
// this fixture compiled against.
const described = link.describe(&buffer) orelse fail("describe was not answered");
if (!std.mem.eql(u8, described.name, contract.name)) fail("describe named a different protocol");
if (described.description.version != contract.version) fail("describe answered the wrong version");
if (described.description.operation_count != contract.operations) fail("describe counted the wrong number of verbs");
// 2. A number no protocol wears. Nothing in the provider looks at it; the
// dispatch table finds no handler and refuses, identically everywhere.
var into: [envelope.packet_maximum]u8 = undefined;
const answered = link.call(.{ .operation = stranger_operation }, &.{}, &into) orelse
fail("a stranger verb was not answered at all");
if (answered.status.status != -envelope.ENOSYS) fail("a stranger verb did not answer -ENOSYS");
if (answered.status.len != 0) fail("a refused verb promised a payload");
if (answered.capability) |handle| {
_ = ipc.close(handle);
fail("a refused verb handed back a capability");
}
// 3. A refusal is an answer, not a wedge so the same channel still works.
const again = link.describe(&buffer) orelse fail("the provider stopped answering after a refused verb");
if (!std.mem.eql(u8, again.name, contract.name)) fail("describe changed its answer after a refused verb");
report("protocol-conformance: {s} v{d} describes itself ({d} verbs), verb {d} -> -ENOSYS\n", .{
contract.name,
contract.version,
contract.operations,
stranger_operation,
});
}
fn contractIndex(name: []const u8) ?usize {
for (contracts, 0..) |contract, index| {
if (std.mem.eql(u8, contract.name, name)) return index;
}
return null;
}
fn run() void {
const registry = registryEndpoint() orelse fail("resolve /protocol");
// Every contract this scenario is supposed to be able to check must be bound
// before the listing is taken, or the case would assert nothing on a slow
// boot instead of failing on a broken one.
for (contracts) |contract| {
if (contract.required) awaitListed(registry, contract.name);
}
takeListing(registry);
if (listed_count == 0) fail("/protocol listed nothing at all");
report("protocol-conformance: /protocol lists {d} contract(s)\n", .{listed_count});
var checked = [_]bool{false} ** contracts.len;
for (0..listed_count) |index| {
const name = listedName(index);
const found = contractIndex(name) orelse {
// Not a lie of omission: named on serial, with the reason.
report("protocol-conformance: {s} skipped — not built on envelope.Define yet\n", .{name});
continue;
};
const contract = contracts[found];
const link = channel.Channel.connect(name) orelse {
// P3 is in force: an ungranted name is absent for this binary, and
// that is a manifest fact, not a failure unless the scenario is
// supposed to have granted it.
if (contract.required) fail("a contract this fixture is granted would not open");
report("protocol-conformance: {s} skipped — not granted to this fixture\n", .{name});
continue;
};
conform(link, contract);
link.close();
checked[found] = true;
}
// The vacuity guard, and the honest tail: a required contract that went
// unchecked fails the case, and every other one this fixture knows how to
// check but found no provider for is named, so the coverage is legible on
// serial rather than inferred from what is absent.
var count: usize = 0;
for (contracts, 0..) |contract, index| {
if (checked[index]) {
count += 1;
continue;
}
if (contract.required) fail("a contract this scenario boots was never conformance-checked");
report("protocol-conformance: {s} not bound in this scenario — no provider to ask\n", .{contract.name});
}
report("protocol-conformance: {d} provider(s) answered the reserved verbs identically\n", .{count});
_ = logging.write("protocol-conformance: ok\n");
}
pub fn main(startup: process.Init) void {
const role = startup.arguments.get(1) orelse return; // bare (ramdisk sweep): stay silent
if (std.mem.eql(u8, role, "run")) run();
}

View File

@ -95,9 +95,13 @@ const Answer = struct {
/// Whether a capability rode the reply. The one field that actually matters /// Whether a capability rode the reply. The one field that actually matters
/// to a client: the capability IS the channel. /// to a client: the capability IS the channel.
capability: bool = false, capability: bool = false,
/// The reply header, decoded compared field by field as well as byte for /// The reply's envelope `Status`, decoded compared field by field as well
/// byte, so a failure says *which* field diverged. /// as byte for byte, so a failure says *which* field diverged.
reply: vfs_protocol.Reply = .{ .status = 0, .node = 0, .len = 0 }, status: envelope.Status = .{ .status = 0, .len = 0 },
/// The node id the reply carried, or null when it carried no reply body at
/// all. A refusal has none; the open of a contract carries a zero, because
/// the capability is the whole answer.
node: ?u64 = null,
fn bytes(self: *const Answer) []const u8 { fn bytes(self: *const Answer) []const u8 {
return self.packet[0..self.length]; return self.packet[0..self.length];
@ -129,33 +133,30 @@ fn registryEndpoint() ?ipc.Handle {
const resolve_attempts: u32 = 200; const resolve_attempts: u32 = 200;
const resolve_retry_ms: u64 = 20; const resolve_retry_ms: u64 = 20;
/// One vfs-protocol request at the registry: the fixed header, then the contract /// One vfs-protocol request at the registry: the folded header, the verb's own
/// name inline. Names go bare (`input`, not `/input`) the registrar normalises /// fixed part, then the contract name as the packet's tail. Names go bare
/// both, and bare is what `bind` sends. /// (`input`, not `/input`) the registrar normalises both, and bare is what
fn transact(registry: ipc.Handle, operation: vfs_protocol.Operation, name: []const u8, cursor: u64) ?Answer { /// `bind` sends.
var request: [vfs_protocol.message_maximum]u8 = undefined; fn transact(
if (vfs_protocol.request_size + name.len > request.len) return null; registry: ipc.Handle,
const header = vfs_protocol.Request{ comptime operation: vfs_protocol.Operation,
.operation = operation, request: vfs_protocol.Protocol.RequestOf(operation),
.node = 0, name: []const u8,
.offset = cursor, ) ?Answer {
.len = @intCast(name.len), var packet: [vfs_protocol.message_maximum]u8 = undefined;
.flags = 0, const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null;
};
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
@memcpy(request[vfs_protocol.request_size..][0..name.len], name);
var answer: Answer = .{}; var answer: Answer = .{};
const got = ipc.callCap( const got = ipc.callCap(registry, framed, &answer.packet, null) catch return null;
registry,
request[0 .. vfs_protocol.request_size + name.len],
&answer.packet,
null,
) catch return null;
if (got.len < vfs_protocol.reply_size) return null;
answer.length = got.len; answer.length = got.len;
answer.capability = got.cap != null; answer.capability = got.cap != null;
answer.reply = std.mem.bytesToValue(vfs_protocol.Reply, answer.packet[0..vfs_protocol.reply_size]); answer.status = envelope.statusOf(answer.bytes()) orelse return null;
answer.node = if (operation == .open) blk: {
const opened = vfs_protocol.Protocol.decodeReply(.open, answer.bytes()) orelse break :blk null;
// A short reply decodes as garbage rather than absence, so the promised
// length is what says whether a body is there at all.
break :blk if (answer.status.len < @sizeOf(vfs_protocol.Opened)) null else opened.node;
} else null;
// A capability we did not ask to keep is a handle slot spent; the assertions // A capability we did not ask to keep is a handle slot spent; the assertions
// below only care that one arrived. // below only care that one arrived.
if (got.cap) |handle| _ = ipc.close(handle); if (got.cap) |handle| _ = ipc.close(handle);
@ -165,7 +166,7 @@ fn transact(registry: ipc.Handle, operation: vfs_protocol.Operation, name: []con
/// `open(name)`, kept whole. Null only if the registry could not be reached at /// `open(name)`, kept whole. Null only if the registry could not be reached at
/// all a registrar that answered has decided, and its decision is the subject. /// all a registrar that answered has decided, and its decision is the subject.
fn openContract(registry: ipc.Handle, name: []const u8) Answer { fn openContract(registry: ipc.Handle, name: []const u8) Answer {
return transact(registry, .open, name, 0) orelse fail("the registry stopped answering"); return transact(registry, .open, .{ .flags = 0 }, name) orelse fail("the registry stopped answering");
} }
/// Whether `/protocol` currently lists `name`. The namespace is browsable on /// Whether `/protocol` currently lists `name`. The namespace is browsable on
@ -177,12 +178,11 @@ fn openContract(registry: ipc.Handle, name: []const u8) Answer {
fn listed(registry: ipc.Handle, name: []const u8) bool { fn listed(registry: ipc.Handle, name: []const u8) bool {
var cursor: u64 = 0; var cursor: u64 = 0;
while (cursor < 64) : (cursor += 1) { while (cursor < 64) : (cursor += 1) {
const answer = transact(registry, .readdir, "", cursor) orelse return false; const answer = transact(registry, .readdir, .{ .cursor = cursor }, "") orelse return false;
if (answer.reply.status != 0 or answer.reply.len == 0) return false; // end of directory if (answer.status.status != 0) return false;
const payload = answer.packet[vfs_protocol.reply_size..answer.length]; const entry = vfs_protocol.Protocol.decodeReply(.readdir, answer.bytes()) orelse return false;
if (payload.len < vfs_protocol.directory_entry_size) return false; if (entry.name_len == 0) return false; // end of directory
const entry = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, payload[0..vfs_protocol.directory_entry_size]); const text = vfs_protocol.Protocol.replyTail(.readdir, answer.bytes());
const text = payload[vfs_protocol.directory_entry_size..];
const length = @min(@as(usize, entry.name_len), text.len); const length = @min(@as(usize, entry.name_len), text.len);
if (std.mem.eql(u8, text[0..length], name)) return true; if (std.mem.eql(u8, text[0..length], name)) return true;
} }
@ -207,14 +207,24 @@ fn awaitListed(registry: ipc.Handle, name: []const u8) void {
/// Every caller-visible field of two answers, compared. `step` names the pair so /// Every caller-visible field of two answers, compared. `step` names the pair so
/// a failure says which comparison broke and in which field. /// a failure says which comparison broke and in which field.
fn expectIdentical(step: []const u8, refused: Answer, absent: Answer) void { fn expectIdentical(step: []const u8, refused: Answer, absent: Answer) void {
if (refused.reply.status != absent.reply.status) fail(step); // the errno if (refused.status.status != absent.status.status) fail(step); // the errno
if (refused.reply.node != absent.reply.node) fail(step); // the node id an open would return if (!nodesMatch(refused.node, absent.node)) fail(step); // the node id an open would return
if (refused.reply.len != absent.reply.len) fail(step); // payload bytes promised if (refused.status.len != absent.status.len) fail(step); // payload bytes promised
if (refused.length != absent.length) fail(step); // reply packet length if (refused.length != absent.length) fail(step); // reply packet length
if (refused.capability != absent.capability) fail(step); // the channel itself if (refused.capability != absent.capability) fail(step); // the channel itself
if (!std.mem.eql(u8, refused.bytes(), absent.bytes())) fail(step); // and every byte of it if (!std.mem.eql(u8, refused.bytes(), absent.bytes())) fail(step); // and every byte of it
} }
/// Two node ids agree when both are absent or both are the same value. A refusal
/// carries none at all now the envelope sends a bare `Status` so "no node"
/// is itself one of the observations that has to match.
fn nodesMatch(one: ?u64, other: ?u64) bool {
if (one) |a| {
return if (other) |b| a == b else false;
}
return other == null;
}
fn run() void { fn run() void {
const registry = registryEndpoint() orelse fail("resolve /protocol"); const registry = registryEndpoint() orelse fail("resolve /protocol");
@ -232,14 +242,14 @@ fn run() void {
// 1. The control. A granted, bound contract opens: success, and the // 1. The control. A granted, bound contract opens: success, and the
// capability that IS the channel. // capability that IS the channel.
const allowed = openContract(registry, granted_contract); const allowed = openContract(registry, granted_contract);
if (allowed.reply.status != 0) fail("a granted open was refused"); if (allowed.status.status != 0) fail("a granted open was refused");
if (!allowed.capability) fail("a granted open carried no channel"); if (!allowed.capability) fail("a granted open carried no channel");
_ = logging.write("protocol-denied: granted open succeeded\n"); _ = logging.write("protocol-denied: granted open succeeded\n");
// 2. The refusal. `input` is bound the listing above proved it and no // 2. The refusal. `input` is bound the listing above proved it and no
// manifest row names this binary against it. // manifest row names this binary against it.
const refused = openContract(registry, forbidden_contract); const refused = openContract(registry, forbidden_contract);
if (refused.reply.status != -envelope.ENOENT) fail("an ungranted open did not answer -ENOENT"); if (refused.status.status != -envelope.ENOENT) fail("an ungranted open did not answer -ENOENT");
if (refused.capability) fail("an ungranted open carried a channel"); if (refused.capability) fail("an ungranted open carried a channel");
_ = logging.write("protocol-denied: ungranted open refused as absent\n"); _ = logging.write("protocol-denied: ungranted open refused as absent\n");
@ -259,7 +269,7 @@ fn run() void {
// what it should, so what steps 2-4 saw was policy and not a registry // what it should, so what steps 2-4 saw was policy and not a registry
// that had wedged. // that had wedged.
const again = openContract(registry, granted_contract); const again = openContract(registry, granted_contract);
if (again.reply.status != 0 or !again.capability) fail("the granted contract stopped opening"); if (again.status.status != 0 or !again.capability) fail("the granted contract stopped opening");
_ = logging.write("protocol-denied: ok\n"); _ = logging.write("protocol-denied: ok\n");
} }