From d2dfbcabf82bdd16fce38f8ece96df3b8c58873f Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:15:25 +0100 Subject: [PATCH] library: five protocols speak the envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- build.zig | 1 + build.zig.zon | 1 + docs/device-driver-development/display.md | 6 +- docs/device-driver-development/input.md | 27 +- docs/file-system-development/vfs-protocol.md | 165 +++++---- docs/security-track-plan.md | 10 +- library/client/build.zig | 6 + library/client/display/display-client.zig | 138 +++----- library/client/input/input-client.zig | 46 ++- library/device/block/block.zig | 50 +-- library/device/build.zig | 1 + library/kernel/build.zig | 1 + library/kernel/channel.zig | 48 ++- library/kernel/file-system.zig | 107 +++--- library/protocol/block/block-protocol.zig | 88 +++-- library/protocol/build.zig | 29 +- library/protocol/display/display-protocol.zig | 208 +++++++---- library/protocol/input/input-protocol.zig | 163 ++++++--- library/protocol/scanout/scanout-protocol.zig | 72 ++-- library/protocol/vfs/vfs-protocol.zig | 219 +++++++----- system/abi.zig | 7 +- system/configuration/protocol.csv | 10 + system/drivers/usb-storage/build.zig | 4 +- system/drivers/usb-storage/usb-storage.zig | 110 +++--- system/drivers/virtio-gpu/build.zig | 4 +- system/drivers/virtio-gpu/virtio-gpu.zig | 96 ++--- system/kernel/tests.zig | 64 ++++ system/services/display/backend.zig | 57 +-- system/services/display/build.zig | 5 +- system/services/display/display.zig | 202 ++++++----- system/services/fat/build.zig | 4 +- system/services/fat/fat.zig | 230 ++++++------ system/services/init/init.zig | 80 +++-- system/services/input/build.zig | 2 +- system/services/input/input.zig | 142 ++++---- test/qemu_test.py | 19 + .../protocol-conformance-test/build.zig | 28 ++ .../protocol-conformance-test/build.zig.zon | 20 ++ .../protocol-conformance-test.zig | 332 ++++++++++++++++++ .../protocol-denied-test.zig | 88 +++-- 40 files changed, 1882 insertions(+), 1008 deletions(-) create mode 100644 test/system/services/protocol-conformance-test/build.zig create mode 100644 test/system/services/protocol-conformance-test/build.zig.zon create mode 100644 test/system/services/protocol-conformance-test/protocol-conformance-test.zig diff --git a/build.zig b/build.zig index aac809e..978cee7 100644 --- a/build.zig +++ b/build.zig @@ -340,6 +340,7 @@ pub fn build(b: *std.Build) void { "user-memory-test", // aims deliberately bad user pointers at the checked copy layer "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart "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| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index 3313887..76961d0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -77,6 +77,7 @@ .@"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-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 ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/docs/device-driver-development/display.md b/docs/device-driver-development/display.md index faf06ec..7eadedd 100644 --- a/docs/device-driver-development/display.md +++ b/docs/device-driver-development/display.md @@ -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 (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 -([`service.run`](../../library/kernel/service.zig) with a `protocol.zig` of -`extern struct` messages and an `Operation` tag). +([`service.run`](../../library/kernel/service.zig) over the dispatch table its +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 composites — it does not split a "framebuffer driver" from a "compositor" the way input diff --git a/docs/device-driver-development/input.md b/docs/device-driver-development/input.md index b3a38d6..51d824f 100644 --- a/docs/device-driver-development/input.md +++ b/docs/device-driver-development/input.md @@ -22,12 +22,22 @@ event: - `JoystickEvent` — `axis` moves (a signed value on a `control` index) and `button_down`/`button_up`. -All three travel in one **`InputEvent` envelope** tagged with a `DeviceKind`, so the -fan-out is a single code path and a subscriber can take a mix of classes on one stream. -Decode an envelope with `asKeyboard()` / `asMouse()` / `asJoystick()` (each returns null -unless the tag matches). 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. +A source publishes any of the three as one **`InputEvent`** tagged with a `DeviceKind`, so +`publish` is a single verb; decode one with `asKeyboard()` / `asMouse()` / `asJoystick()` +(each returns null unless the tag matches). On the *delivery* wire the class is the +packet's own operation instead — the protocol declares one event per class +([protocol-namespace.md](../os-development/protocol-namespace.md)), so a pushed packet is +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 @@ -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 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 - 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 published and serviced its own subscription would deadlock (its `publish` call blocks until diff --git a/docs/file-system-development/vfs-protocol.md b/docs/file-system-development/vfs-protocol.md index 8d0f818..41528d6 100644 --- a/docs/file-system-development/vfs-protocol.md +++ b/docs/file-system-development/vfs-protocol.md @@ -6,7 +6,10 @@ > serves it directly (the read-only /system initrd mount, via `fs_node`) or > redirects the caller to the owning backend's endpoint plus the rewritten > mount-relative path — after which the client speaks THIS protocol to the -> backend, unchanged. 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 > 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) @@ -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.) - A message is at most **256 bytes** (`message_maximum`). -- A request is a fixed 32-byte **Request** header followed by an inline - payload of at most **224 bytes** (`maximum_payload`) — a path, or write - bytes. There is no multi-message request: paths and single reads/writes - must fit, and larger transfers loop (see *read* / *write*). -- A reply is a fixed 24-byte **Reply** header followed by an inline payload — - read bytes, a `FileStatus`, or a `DirectoryEntry`. +- Every packet begins with the 16-byte **envelope prefix** + ([protocol-namespace.md](../os-development/protocol-namespace.md)): a + `Header` on a request, a `Status` on a reply. The prefix is **folded, not + stacked** — the verb and the object being addressed live in it, and no + request or reply below repeats either. +- A request is the header, then the verb's own fixed part (0–16 bytes), then + an inline tail of at most **224 bytes** (`maximum_payload`) — a path, or + write bytes. There is no multi-message request: paths and single + reads/writes must fit, and larger transfers loop (see *read* / *write*). +- A reply is the status, then the verb's own fixed part, then an inline tail + — read bytes, or a directory entry's name. - All integers are **little-endian**; layouts are C layout for x86-64 (`extern struct`), offsets given below so nothing need be inferred. @@ -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 exit events. -## Request header — 32 bytes +## Request header — 16 bytes + +The envelope's `Header`, identical in every danos protocol: | offset | size | field | meaning | |-------:|-----:|-------|---------| -| 0 | 4 | `operation` | an **Operation** value (below) | +| 0 | 4 | `operation` | an **Operation** value (below); 0–15 are the reserved universal verbs | | 4 | 4 | — | padding | -| 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations | -| 16 | 8 | `offset` | byte position for read/write; entry index (cursor) for readdir; else 0 | -| 24 | 4 | `len` | payload length for path/write operations; requested byte count for read | -| 28 | 4 | `flags` | open flags (below); else 0 | +| 8 | 8 | `target` | **the open-node id** from a prior `open`; 0 for `open` itself and the path-based verbs | -## Reply header — 24 bytes +## Reply header — 16 bytes + +The envelope's `Status`: | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 4 | `status` | **0 = success**, negative = failure (signed) | | 4 | 4 | — | padding | -| 8 | 8 | `node` | the new open-node id (for `open`); else 0 | -| 16 | 4 | `len` | reply payload length in bytes | -| 20 | 4 | — | padding | +| 8 | 4 | `len` | reply bytes following this header: the verb's fixed part plus its tail | +| 12 | 4 | — | padding | -On failure the backend replies `status = -1`, and that reply reaches the -client directly — there is no party between them on the wire. (Kernel-served -paths produce no wire replies at all: `fs_resolve`/`fs_node` failures are -syscall register statuses.) A richer errno vocabulary is future work — -clients must treat *any* negative status as failure, not match on -1. +A failing backend replies with the status alone (`len` = 0) and no fixed +part, and that reply reaches the client directly — there is no party between +them on the wire. (Kernel-served paths produce no wire replies at all: +`fs_resolve`/`fs_node` failures are syscall register statuses.) The errno +vocabulary is the kernel's, continued by the envelope: `ENOENT` = 4 is what a +backend answers for anything it cannot find or cannot do, `ENOSYS` = 10 for a +verb it does not implement, `EPROTO` = 11 for a packet shorter than the verb +it names. Clients must treat *any* negative status as failure rather than +matching a particular one. ## Operations -Values are append-only and never renumbered (the same evolution rule every -danos protocol follows). Send only values from this table: the shipped server -decodes the operation into an exhaustive enum, so an out-of-range value is -not answered with a `status = -1` reply — it trips a safety check in safe -builds and is undefined otherwise. (The `-1` replies cover recognised but -refused operations, such as `mount` sent to a backend.) +Values number from 16 (`first_protocol_operation`) in declaration order, and +are frozen once shipped. Values 0–15 are the envelope's reserved universal +verbs, which mean the same thing at every provider in the system: `describe` +(0) answers the protocol's name and version and is implemented by the +envelope itself, so every backend answers it. A verb outside this table is +answered `-ENOSYS`; it is never a safety check any more, because the +dispatch compares numbers rather than decoding an enum. -| value | operation | request payload | reply | -|------:|-----------|-----------------|-------| -| 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id | -| 1 | `close` | — (`node` set) | status only | -| 2 | `read` | — (`node`, `offset`, `len` = wanted count) | `len` bytes read, payload = the bytes; `len` 0 at end of file | -| 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) | -| 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) | -| 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end | -| 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only | -| 7 | `unmount` | the mount-point path | status only | -| 8 | `mkdir` | the path | status only | -| 9 | `unlink` | the path | status only | -| 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only | +Each row's *request* and *reply* name the bytes **after** the 16-byte prefix. + +| value | operation | request | tail | reply | reply tail | +|------:|-----------|---------|------|-------|-----------| +| 16 | `open` | `flags` (4 bytes, below) | the path | `node` (8 bytes) = the open-node id | — | +| 17 | `close` | — | — | — | — | +| 18 | `read` | `offset` (8), `len` (4) = wanted count | — | — | the bytes read; `Status.len` 0 at end of file | +| 19 | `write` | `offset` (8), `len` (4) = count | the bytes | `count` (4) = bytes accepted (may be short — loop) | — | +| 20 | `status` | — | — | **FileStatus** (24 bytes) | — | +| 21 | `readdir` | `cursor` (8) | — | one **DirectoryEntry** (16 bytes) | the name | +| 22 | `mount` | — | the mount-point path; the backend endpoint rides as the call's **capability** | — | — | +| 23 | `unmount` | — | the mount-point path | — | — | +| 24 | `mkdir` | — | the path | — | — | +| 25 | `unlink` | — | the path | — | — | +| 26 | `rename` | — | old path, one `0x00`, new path | — | — | +| 27 | `bind` | — | the contract name; the provider's endpoint rides as the call's **capability** | — | — | Notes per operation: - **open** — the path is the mount-relative path `fs_resolve` handed back - (absolute-shaped: `/notes.txt` under fat's `/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` refuses non-absolute paths. The returned `node` is the *backend's* own open-node id: with the router in the kernel there is no forwarding table, - and clients hold backend ids directly (see *Lifetimes and trust*). + and clients hold backend ids directly (see *Lifetimes and trust*). Every + later packet carries it in `Header.target` — the path is spoken once, here, + and integers do the rest. - **read / write** — a single exchange moves at most 224 bytes - (`maximum_payload`); the client loops, advancing `offset` by the returned - `len`, until done (read) or the slice is written (write). A `write` reply - shorter than requested is progress, not an error; a `len` of 0 means no - forward progress — stop rather than spin. -- **readdir** — `offset` is a **cursor: the entry index**, not a byte - position. Each call returns exactly one entry; the client increments the - cursor by 1. A reply with `len` 0 is end-of-directory. The directory must - have been opened with the `directory` flag. + (`maximum_payload`); the client loops, advancing its own offset by what + came back, until done (read) or the slice is written (write). A `write` + reply shorter than requested is progress, not an error; a count of 0 means + no forward progress — stop rather than spin. +- **readdir** — `cursor` is the **entry index**, not a byte position. Each + call returns exactly one entry; the client increments the cursor by 1. **A + `name_len` of 0 is end-of-directory** — the reply's own length cannot say + so, because the envelope always sends the fixed reply part. The directory + must have been opened with the `directory` flag. - **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount` syscall now (a filesystem server passes its endpoint handle; possession is - the capability, exactly the trust of the old cap-passing op). The op + the capability, exactly the trust of the old cap-passing op). The verb numbers stay reserved. Mount-prefix semantics are unchanged: prefixes - match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`), - the longest matching prefix wins, and an optional backend-side rewrite - prefix maps a mount into the backend's namespace (fat serves `/mnt/usb` - from its volume root and `/var` from its `/var` subtree). + match at path boundaries only (`/volumes/usb` never captures + `/volumes/usbextra`), the longest matching prefix wins, and an optional + backend-side rewrite prefix maps a mount into the backend's namespace (fat + serves `/volumes/usb` from its volume root and `/system/logs` from its + `/system/logs` subtree). - **rename** — same-directory rename only: the backend compares the old and new parent paths and refuses a mismatch. The client (`file_system`) refuses earlier when the two paths resolve to different backend endpoints, but that check is coarser than "one mount" — one endpoint can serve several mounts - (fat serves `/mnt/usb` and `/var`), so a cross-mount rename reaches the - backend and fails on its same-directory check. + (fat serves `/volumes/usb`, `/system/configuration` and `/system/logs`), so + a cross-mount rename reaches the backend and fails on its same-directory + check. +- **bind** — the protocol registry's claim verb, implemented only by the + synthetic `/protocol` backend inside PID 1 + ([protocol-namespace.md](../os-development/protocol-namespace.md)). A file + backend answers `-ENOSYS`. ## Open flags -Bitwise OR in `Request.flags`, meaningful for `open` only: +Bitwise OR in `open`'s `flags`, meaningful for `open` only: | 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 | | 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 | |-------:|-----:|-------|---------| @@ -138,12 +163,12 @@ Bitwise OR in `Request.flags`, meaningful for `open` only: | 12 | 4 | — | padding | | 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none | -## DirectoryEntry — 16 bytes + name (the `readdir` reply payload) +## DirectoryEntry — 16 bytes + name (the `readdir` reply) | offset | size | field | meaning | |-------:|-----:|-------|---------| | 0 | 4 | `kind` | a **NodeKind** value | -| 4 | 4 | `name_len` | length of the name that follows | +| 4 | 4 | `name_len` | length of the name that follows; **0 means end of directory** | | 8 | 8 | `size` | the entry's size in bytes | | 16 | `name_len` | name | the entry's name, not NUL-terminated | @@ -174,7 +199,7 @@ volumes, reserved) remain part of the design. ## An open reply may carry a capability `open` rides `ipc_call`, whose reply direction can hand back an endpoint -capability alongside the `Reply` 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: opening a `protocol` node returns the provider's endpoint, and possession of 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 **append-only and frozen once shipped**. The unit test in - `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry` - size, `NodeKind` 0–1 and 6–7, `Operation` values 0, 4 and 5); this page is - the full record of the frozen values. + `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the + `DirectoryEntry` size, `NodeKind` 0–1 and 6–7, `Operation` values 16–21, 26 + and 27); this page is the full record of the frozen values. + *The one renumbering this contract has had was the rebase onto the envelope + (P4a), which moved every verb above the reserved range — a deliberate + flag-day across a system with no third-party clients yet, not a precedent.* - The 256-byte message ceiling is a property of the current IPC transport, not a promise; clients should read `maximum_payload`-shaped limits from the reply lengths they actually get (loop-until-done), not hard-code 224. -- Negative statuses beyond -1 will appear (an errno vocabulary); success is - exactly 0. +- Success is exactly 0, and the negative statuses come from one system-wide + errno vocabulary (the kernel's, continued by the envelope) rather than from + this protocol. diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 5c3dae5..c9c1b09 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.** | | | |---|---| -| Working on | **P4a** — protocol rebase onto `envelope.Define` | -| Branch carrying it | `feat/security-group-3` (cut next) | +| Working on | **P4b** — device-manager, power, usb-transfer onto `Define` | +| Branch carrying it | `feat/security-group-3` (pushed to origin) | | On `main` | Phase 0, PM, H1, P1, P2, P3 — groups 1 and 2 merged | -| Awaiting merge | nothing | -| Suite | 109 cases, all passing | +| Awaiting merge | P4a — lands with the group 3 merge | +| Suite | 110 cases, all passing | | Last updated | 2026-08-01 | 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 bind attestation and every refusal it makes untouched (suite 109/109) - [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) - [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers - [ ] **merge** group 3 → main, push diff --git a/library/client/build.zig b/library/client/build.zig index 0c1f5f0..b090548 100644 --- a/library/client/build.zig +++ b/library/client/build.zig @@ -17,10 +17,15 @@ pub fn build(b: *std.Build) void { // (docs/os-development/protocol-namespace.md). 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", .{ .root_source_file = b.path("display/display-client.zig"), .imports = &.{ .{ .name = "channel", .module = channel }, + .{ .name = "envelope", .module = envelope }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .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"), .imports = &.{ .{ .name = "channel", .module = channel }, + .{ .name = "envelope", .module = envelope }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "input-protocol", .module = protocol.module("input-protocol") }, diff --git a/library/client/display/display-client.zig b/library/client/display/display-client.zig index 0f2c106..dfad7b2 100644 --- a/library/client/display/display-client.zig +++ b/library/client/display/display-client.zig @@ -5,10 +5,13 @@ const std = @import("std"); const channel = @import("channel"); +const envelope = @import("envelope"); const ipc = @import("ipc"); const time = @import("time"); const display_protocol = @import("display-protocol"); +const Protocol = display_protocol.Protocol; + /// The display's current mode, as `info()` reports it. pub const Info = struct { width: u32, @@ -35,29 +38,45 @@ fn service() ?ipc.Handle { return null; } -/// Send one request, receive its reply; true on a zero status. `out` receives the reply -/// so callers can read `info`/`layer` fields on success. -fn transact(request: display_protocol.Request, out: *display_protocol.Reply) bool { - const h = service() orelse return false; - var req = request; - var reply: [display_protocol.reply_size]u8 = undefined; - const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false; - if (len < display_protocol.reply_size) return false; - out.* = std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]); - return out.status == 0; +/// A reply the compositor answered with, kept whole so the caller can decode the +/// verb's own fixed part out of it. +const Answered = struct { + packet: [display_protocol.message_maximum]u8, + len: usize, + + fn bytes(self: *const Answered) []const u8 { + return self.packet[0..self.len]; + } +}; + +/// 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. pub fn info() ?Info { - var reply: display_protocol.Reply = undefined; - if (!transact(.{ .operation = @intFromEnum(display_protocol.Operation.info) }, &reply)) return null; + const answered = transact(.info, 0, {}, &.{}) orelse 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 }; } /// Composite the dirty layers and flush the frame to the screen. pub fn present() bool { - var reply: display_protocol.Reply = undefined; - return transact(.{ .operation = @intFromEnum(display_protocol.Operation.present) }, &reply); + return transact(.present, 0, {}, &.{}) != null; } /// 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 /// (zero on the GOP floor, or if the service never came up). pub fn modes(out: []Mode) usize { - const h = service() orelse return 0; - var request = display_protocol.Request{ .operation = @intFromEnum(display_protocol.Operation.get_modes) }; - var reply: [display_protocol.modes_reply_size]u8 = undefined; - const len = ipc.call(h, std.mem.asBytes(&request), &reply) catch return 0; - 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]; + const answered = transact(.get_modes, 0, {}, &.{}) orelse return 0; + const offered = Protocol.decodeReply(.get_modes, answered.bytes()) orelse return 0; + const count = @min(@min(offered.count, display_protocol.max_modes), out.len); + for (0..count) |i| out[i] = offered.modes[i]; return count; } /// 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. pub fn setMode(width: u32, height: u32) bool { - var reply: display_protocol.Reply = undefined; - const changed = transact(.{ .operation = @intFromEnum(display_protocol.Operation.set_mode), .width = width, .height = height }, &reply); + const changed = transact(.set_mode, 0, .{ .width = width, .height = height }, &.{}) != null; if (changed) mode = null; // the cached mode is stale now 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 /// 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). +/// +/// 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 { id: u32, /// 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 { - var reply: display_protocol.Reply = undefined; - 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); + return transact(.fill_rect, self.id, .{ .x = x, .y = y, .width = w, .height = h, .colour = colour }, &.{}) != null; } /// 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 - /// `display_protocol.maximum_payload`. + /// layer at (`x`, `y`). The tile rides inline as the request's tail, so `w*h*4` must + /// 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 { - var request = display_protocol.Request{ - .operation = @intFromEnum(display_protocol.Operation.blit_tile), - .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; + if (pixels.len > display_protocol.maximum_payload) return false; + return transact(.blit_tile, self.id, .{ .x = x, .y = y, .width = w, .height = h }, pixels) != null; } /// Move / restack / show or hide the layer. pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool { - var reply: display_protocol.Reply = undefined; - 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); + return transact(.configure_layer, self.id, .{ .x = x, .y = y, .z = z, .visible = if (visible) 1 else 0 }, &.{}) != null; } /// 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. pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool { - var reply: display_protocol.Reply = undefined; - return transact(.{ - .operation = @intFromEnum(display_protocol.Operation.damage), - .layer = self.id, - .x = @bitCast(x), - .y = @bitCast(y), - .width = w, - .height = h, - }, &reply); + return transact(.damage, self.id, .{ .x = x, .y = y, .width = w, .height = h }, &.{}) != null; } /// Release the layer and its surface. pub fn destroy(self: Layer) bool { - var reply: display_protocol.Reply = undefined; - return transact(.{ .operation = @intFromEnum(display_protocol.Operation.destroy_layer), .layer = self.id }, &reply); + return transact(.destroy_layer, self.id, {}, &.{}) != null; } }; /// 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. pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer { - var reply: display_protocol.Reply = undefined; - if (!transact(.{ - .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 }; + const answered = transact(.create_layer, 0, .{ .x = x, .y = y, .width = w, .height = h, .z = z, .visible = 1 }, &.{}) orelse return null; + return .{ .id = (Protocol.decodeReply(.create_layer, answered.bytes()) orelse return null).layer }; } diff --git a/library/client/input/input-client.zig b/library/client/input/input-client.zig index f0818df..fcdbda0 100644 --- a/library/client/input/input-client.zig +++ b/library/client/input/input-client.zig @@ -21,12 +21,14 @@ //! if (event.asKeyboard()) |k| { ... } else if (event.asMouse()) |m| { ... } //! } -const std = @import("std"); const channel = @import("channel"); +const envelope = @import("envelope"); const ipc = @import("ipc"); const time = @import("time"); const input_protocol = @import("input-protocol"); +const Protocol = input_protocol.Protocol; + pub const DeviceKind = input_protocol.DeviceKind; pub const InputEvent = input_protocol.InputEvent; 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 /// was handed to the service as a capability at subscribe time). 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 /// 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 /// (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 { const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null); - if (!got.isMessage() or got.len < input_protocol.event_size) return null; - return std.mem.bytesToValue(InputEvent, self.receive[0..input_protocol.event_size]); + if (!got.isMessage()) return null; + 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 /// `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 { const service = lookupService() 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 reply: [input_protocol.reply_size]u8 = undefined; - const result = ipc.callCap(service, std.mem.asBytes(&request), &reply, endpoint) catch return null; - if (result.len < input_protocol.reply_size) return null; - if (std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status != 0) return null; + var packet: [input_protocol.message_maximum]u8 = undefined; + const framed = input_protocol.encodeSubscribe(device_mask, &packet) orelse return null; + var reply: [input_protocol.message_maximum]u8 = undefined; + const result = ipc.callCap(service, framed, &reply, endpoint) catch return null; + const status = envelope.statusOf(reply[0..result.len]) orelse return null; + if (status.status != 0) return null; return .{ .endpoint = endpoint }; } @@ -149,11 +164,12 @@ pub const Publisher = struct { service: ipc.Handle, fn publish(self: Publisher, event: InputEvent) bool { - var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.publish), .event = event }; - var reply: [input_protocol.reply_size]u8 = undefined; - const len = ipc.call(self.service, std.mem.asBytes(&request), &reply) catch return false; - if (len < input_protocol.reply_size) return false; - return std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status == 0; + var packet: [input_protocol.message_maximum]u8 = undefined; + const framed = Protocol.encodeRequest(.publish, 0, event, &.{}, &packet) orelse return false; + var reply: [input_protocol.message_maximum]u8 = undefined; + const len = ipc.call(self.service, framed, &reply) catch return false; + 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. diff --git a/library/device/block/block.zig b/library/device/block/block.zig index b2e702c..47c17f5 100644 --- a/library/device/block/block.zig +++ b/library/device/block/block.zig @@ -7,12 +7,14 @@ //! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size //! limit — the same handoff usb-storage uses toward the controller. -const std = @import("std"); const channel = @import("channel"); +const envelope = @import("envelope"); const ipc = @import("ipc"); const time = @import("time"); const block_protocol = @import("block-protocol"); +const Protocol = block_protocol.Protocol; + pub const Geometry = struct { block_size: u32, block_count: u64 }; pub const Device = struct { @@ -20,12 +22,9 @@ pub const Device = struct { /// The device's block size and total block count. 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.reply_size]u8 = undefined; - const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch 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; + var reply: [block_protocol.message_maximum]u8 = undefined; + const answered = self.call(.geometry, {}, null, &reply) orelse return null; + const result = Protocol.decodeReply(.geometry, answered) orelse return null; 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 /// in `read`/`write`. Harmless success when no IOMMU is enforcing. 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.reply_size]u8 = undefined; - 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; + var reply: [block_protocol.message_maximum]u8 = undefined; + return self.call(.attach, {}, handle, &reply) != null; } /// Read `count` blocks starting at `lba` into the DMA buffer at `physical`. 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`. 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 /// prior writes survive a power-off. A filesystem calls this before the machine /// goes down; no data transfer, so the buffer arguments are unused. 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 { - var request = block_protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical }; - var reply: [block_protocol.reply_size]u8 = undefined; - const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return false; - if (n < block_protocol.reply_size) return false; - return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0; + /// One request at the driver. `target` is always 0: one endpoint per device, so + /// there is no object within the peer to address. + fn call( + self: Device, + comptime operation: Protocol.Operation, + 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]; } }; diff --git a/library/device/build.zig b/library/device/build.zig index 1be56f1..e9ee388 100644 --- a/library/device/build.zig +++ b/library/device/build.zig @@ -99,6 +99,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("block/block.zig"), .imports = &.{ .{ .name = "channel", .module = channel }, + .{ .name = "envelope", .module = protocol.module("envelope") }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "block-protocol", .module = protocol.module("block-protocol") }, diff --git a/library/kernel/build.zig b/library/kernel/build.zig index 081d3ec..0b845b2 100644 --- a/library/kernel/build.zig +++ b/library/kernel/build.zig @@ -59,6 +59,7 @@ pub fn build(b: *std.Build) void { .{ .name = "system-call", .module = system_call }, .{ .name = "ipc", .module = ipc }, .{ .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): diff --git a/library/kernel/channel.zig b/library/kernel/channel.zig index 07b966f..9fe2e91 100644 --- a/library/kernel/channel.zig +++ b/library/kernel/channel.zig @@ -192,41 +192,37 @@ fn reach(path: []const u8) ?Registry { } } -/// One vfs-protocol round trip at a backend: fixed header, inline payload, and -/// an optional capability in each direction. +/// One vfs-protocol round trip at a backend: the folded header, the verb's own +/// 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( + comptime operation: vfs_protocol.Operation, handle: ipc.Handle, - operation: vfs_protocol.Operation, - payload: []const u8, + request: vfs_protocol.Protocol.RequestOf(operation), + name: []const u8, send_capability: ?ipc.Handle, -) ?struct { reply: vfs_protocol.Reply, capability: ?ipc.Handle } { - var request: [vfs_protocol.message_maximum]u8 = undefined; - if (vfs_protocol.request_size + payload.len > request.len) 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); +) ?struct { status: envelope.Status, capability: ?ipc.Handle } { + var packet: [vfs_protocol.message_maximum]u8 = undefined; + const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null; 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; - if (answer.len < vfs_protocol.reply_size) return null; - return .{ - .reply = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]), - .capability = answer.cap, - }; + const answer = ipc.callCap(handle, framed, &reply, send_capability) catch return null; + const status = envelope.statusOf(reply[0..answer.len]) orelse return null; + return .{ .status = status, .capability = answer.cap }; } /// Resolve an absolute `/protocol/...` path and take the provider's endpoint out /// of the open reply's capability. fn openPath(path: []const u8) ?ipc.Handle { const registry = reach(path) orelse return null; - const answered = transact(registry.handle, .open, registry.path(), null) orelse return null; - if (answered.reply.status != 0) return null; + const answered = transact(.open, registry.handle, .{ .flags = 0 }, registry.path(), null) orelse 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 // answered by a file backend, which does not speak protocols. return answered.capability; @@ -257,8 +253,8 @@ pub fn openEndpoint(name: []const u8) ?ipc.Handle { /// `-EBUSY` a live provider already holds it. pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 { const registry = reach(root) orelse return null; - const answered = transact(registry.handle, .bind, name, endpoint) orelse return null; - return answered.reply.status; + const answered = transact(.bind, registry.handle, {}, name, endpoint) orelse return null; + return answered.status.status; } /// How long a provider keeps offering itself before giving up. The registry is diff --git a/library/kernel/file-system.zig b/library/kernel/file-system.zig index f23b37c..30d8986 100644 --- a/library/kernel/file-system.zig +++ b/library/kernel/file-system.zig @@ -14,8 +14,13 @@ const std = @import("std"); const abi = @import("abi"); const sc = @import("system-call"); const ipc = @import("ipc"); +const envelope = @import("envelope"); 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 /// wire protocol. 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: [Request header][send payload] -> backend -> -// [Reply header][receive payload]. The receive payload lands in `out`. -fn transact(h: ipc.Handle, request: vfs_protocol.Request, send: []const u8, out: []u8) ?Result { - var message: [vfs_protocol.message_maximum]u8 = undefined; - @memcpy(message[0..vfs_protocol.request_size], std.mem.asBytes(&request)); - const slen = @min(send.len, vfs_protocol.maximum_payload); - @memcpy(message[vfs_protocol.request_size..][0..slen], send[0..slen]); - - var rbuf: [vfs_protocol.message_maximum]u8 = undefined; - const n = ipc.call(h, message[0 .. vfs_protocol.request_size + slen], &rbuf) catch return null; - if (n < vfs_protocol.reply_size) return null; - const reply = std.mem.bytesToValue(vfs_protocol.Reply, rbuf[0..vfs_protocol.reply_size]); - const rpl = @min(n - vfs_protocol.reply_size, out.len); - @memcpy(out[0..rpl], rbuf[vfs_protocol.reply_size..][0..rpl]); - return .{ .reply = reply, .payload = out[0..rpl] }; +// 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 +// helpers. A backend that refused (a negative status) reads as null, which is +// what every caller here did with it anyway. +fn transact( + comptime operation: Protocol.Operation, + handle: ipc.Handle, + target: u64, + request: Protocol.RequestOf(operation), + tail: []const u8, + reply: []u8, +) ?[]u8 { + var packet: [vfs_protocol.message_maximum]u8 = undefined; + const framed = Protocol.encodeRequest(operation, target, request, tail, &packet) orelse return null; + const n = ipc.call(handle, framed, reply) catch return null; + const status = envelope.statusOf(reply[0..n]) orelse return null; + 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. @@ -125,11 +131,13 @@ pub const File = struct { return n; }; 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 }; - const r = transact(h, request, &.{}, buffer) orelse return null; - if (r.reply.status != 0) return null; - self.offset += r.reply.len; - return r.reply.len; + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answered = transact(.read, h, self.node, .{ .offset = self.offset, .len = want }, &.{}, &reply) orelse return null; + const bytes = Protocol.replyTail(.read, answered); + const n = @min(bytes.len, buffer.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 @@ -139,11 +147,11 @@ pub const File = struct { pub fn write(self: *File, data: []const u8) ?usize { const h = self.backend orelse return null; 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 }; - const r = transact(h, request, data[0..want], &.{}) orelse return null; - if (r.reply.status != 0) return null; - self.offset += r.reply.len; - return r.reply.len; + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answered = transact(.write, h, self.node, .{ .offset = self.offset, .len = want }, data[0..want], &reply) orelse return null; + const written = Protocol.decodeReply(.write, answered) orelse return null; + self.offset += written.count; + return written.count; } /// 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; 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 buffer: [@sizeOf(vfs_protocol.FileStatus)]u8 = undefined; - const r = transact(h, request, &.{}, &buffer) 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)]); + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answered = transact(.status, h, self.node, {}, &.{}, &reply) orelse return null; + const status = Protocol.decodeReply(.status, answered) orelse return null; 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. pub fn close(self: *File) void { const h = self.backend orelse return; - const request = vfs_protocol.Request{ .operation = .close, .node = self.node, .offset = 0, .len = 0, .flags = 0 }; - _ = transact(h, request, &.{}, &.{}); + var reply: [vfs_protocol.message_maximum]u8 = undefined; + _ = 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 }, .backend => |b| { const relative = route.backendPath(); - const request = vfs_protocol.Request{ .operation = .open, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = options.wireFlags() }; - const r = transact(b.handle, request, relative, &.{}) orelse return null; - if (r.reply.status != 0) return null; - return .{ .node = r.reply.node, .backend = b.handle }; + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answered = transact(.open, b.handle, 0, .{ .flags = options.wireFlags() }, relative, &reply) orelse return null; + const opened = Protocol.decodeReply(.open, answered) orelse return null; + return .{ .node = opened.node, .backend = b.handle }; }, } } @@ -248,15 +254,13 @@ pub const Directory = struct { self.cursor += 1; return true; }; - const request = vfs_protocol.Request{ .operation = .readdir, .node = self.node, .offset = self.cursor, .len = 0, .flags = 0 }; - var buffer: [vfs_protocol.message_maximum]u8 = undefined; - const r = transact(h, request, &.{}, &buffer) orelse return false; - if (r.reply.status != 0 or r.reply.len == 0) return false; // error or EOF - 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]); + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answered = transact(.readdir, h, self.node, .{ .cursor = self.cursor }, &.{}, &reply) orelse return false; + const header = Protocol.decodeReply(.readdir, answered) orelse return false; + if (header.name_len == 0) return false; // end of directory entry.kind = kindFromWire(header.kind); 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); @memcpy(entry.name_buffer[0..nlen], source[0..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 // paths (the read-only /system) refuse mutation by construction: the resolve // 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; if (route != .backend) return false; - const relative = route.backendPath(); - const request = vfs_protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = 0 }; - const r = transact(route.backend.handle, request, relative, &.{}) orelse return false; - return r.reply.status == 0; + var reply: [vfs_protocol.message_maximum]u8 = undefined; + return transact(operation, route.backend.handle, 0, {}, route.backendPath(), &reply) != null; } /// 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); payload[old_relative.len] = 0; @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 }; - const r = transact(old_route.backend.handle, request, payload[0..total], &.{}) orelse return false; - return r.reply.status == 0; + var reply: [vfs_protocol.message_maximum]u8 = undefined; + return transact(.rename, old_route.backend.handle, 0, {}, payload[0..total], &reply) != null; } /// Mount a filesystem backend (its server endpoint) at absolute path `target`; diff --git a/library/protocol/block/block-protocol.zig b/library/protocol/block/block-protocol.zig index 530d430..339c7fe 100644 --- a/library/protocol/block/block-protocol.zig +++ b/library/protocol/block/block-protocol.zig @@ -1,51 +1,61 @@ //! 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 -//! module like vfs-protocol / usb-transfer-protocol: extern-struct messages, an -//! `Operation` tag, everything in one IPC message. +//! block driver (usb-storage) over `/protocol/block`. Defined through the +//! envelope, so every packet begins with the folded `Header`. +//! +//! **`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 //! 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 -//! sector never has to cross the 256-byte IPC boundary; only the small request / -//! reply headers do. Under an enforcing IOMMU the buffer's physical addresses are -//! only 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. +//! sector never has to cross the packet floor; only the small request / reply +//! parts do. Under an enforcing IOMMU the buffer's physical addresses are only +//! 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. -pub const Operation = enum(u32) { - /// geometry() -> { block_size, block_count } - geometry = 0, - /// read(lba, count, physical): read `count` blocks from `lba` into the buffer - read = 1, - /// write(lba, count, physical): write `count` blocks at `lba` from the buffer - write = 2, - /// 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, +const envelope = @import("envelope"); + +/// The answer to `geometry()`. +pub const Geometry = extern struct { + block_size: u32, // bytes per block (512) + _padding: u32 = 0, + block_count: u64, // total blocks }; -pub const Request = extern struct { - operation: u32, - reserved: u32 = 0, +/// `read(lba, count, physical)` / `write(...)`: move `count` blocks between the +/// device and the caller's DMA buffer at `physical`. +pub const Transfer = extern struct { lba: u64, - count: u32, // number of blocks (read/write) - reserved2: u32 = 0, - physical: u64, // caller's DMA buffer physical address (read/write) + count: u32, + _padding: u32 = 0, + physical: u64, // caller's DMA buffer physical address }; -pub const Reply = extern struct { - status: i32, // 0 on success, negative on failure - reserved: u32 = 0, - block_size: u32, // geometry: bytes per block (512) - reserved2: u32 = 0, - block_count: u64, // geometry: total blocks; read/write: blocks moved -}; +/// How many blocks a transfer actually moved. +pub const Transferred = extern struct { count: u32 }; -pub const message_maximum: usize = 256; -pub const request_size: usize = @sizeOf(Request); -pub const reply_size: usize = @sizeOf(Reply); +pub const Protocol = envelope.Define(.{ + .name = "block", + .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; diff --git a/library/protocol/build.zig b/library/protocol/build.zig index a167066..c78b123 100644 --- a/library/protocol/build.zig +++ b/library/protocol/build.zig @@ -3,7 +3,8 @@ //! every conversation depend on the contract by name; neither reaches into the //! 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) //! @@ -19,10 +20,12 @@ const std = @import("std"); 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 }{ - // 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 = "input-protocol", .root = "input/input-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 = "power-protocol", .root = "power/power-protocol.zig" }, }) |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 // its aggregate test step. 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{ - "envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors "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 }) |root| { const protocol_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path(root), .target = b.resolveTargetQuery(.{}), + .imports = &.{.{ .name = "envelope", .module = envelope }}, }), }); test_step.dependOn(&b.addRunArtifact(protocol_tests).step); diff --git a/library/protocol/display/display-protocol.zig b/library/protocol/display/display-protocol.zig index 11ead14..2898568 100644 --- a/library/protocol/display/display-protocol.zig +++ b/library/protocol/display/display-protocol.zig @@ -1,93 +1,137 @@ -//! The display wire protocol — what a client says to the display service over its -//! well-known `.display` endpoint. extern-struct messages with an `Operation` tag, the -//! same shape as block/vfs/input protocols. The compositor owns the framebuffer and an -//! ordered stack of **layers**; a client creates layers, draws into them with these -//! operations, marks damage, and asks for a `present`. v1 surfaces are server-owned (a -//! client draws by command); shared-memory surfaces are a later milestone (docs/display.md). +//! The display wire protocol — what a client says to the display service over +//! `/protocol/display`. The compositor owns the framebuffer and an ordered stack of +//! **layers**; a client creates layers, draws into them with these operations, marks damage, +//! and asks for a `present`. v1 surfaces are server-owned (a 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"); -pub const Operation = enum(u32) { - /// info() -> { width, height, pitch, format }: the display's current mode. - 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, ): 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) + : 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, +/// The answer to `info()`: the display's current mode. +pub const Info = extern struct { width: u32 = 0, height: u32 = 0, - z: u32 = 0, // create_layer / configure_layer: stacking order (higher = in front) - 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, + pitch: u32 = 0, // bytes per row (may exceed width*4) 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. pub const Mode = extern struct { width: u32, height: u32 }; pub const max_modes = 4; -/// The reply to `get_modes`: a small fixed list of resolutions the display can switch to. -pub const ModesReply = extern struct { - status: i32, - count: u32, - modes: [max_modes]Mode, +/// The answer to `get_modes`: the resolutions the display can switch to (empty on GOP). +pub const Modes = extern struct { + count: u32 = 0, + _padding: u32 = 0, + 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, -/// system/kernel/ipc-synchronous.zig), so this matches it (a larger receive/reply buffer -/// is rejected with -E2BIG). A `blit_tile` therefore carries only a *small* tile inline — -/// `maximum_payload` bytes = up to 54 pixels, enough for a cursor or small sprite; larger -/// bitmaps are the deferred shared-memory surface path (docs/display.md). -pub const message_maximum: usize = 256; -pub const request_size: usize = @sizeOf(Request); -pub const reply_size: usize = @sizeOf(Reply); -pub const maximum_payload: usize = message_maximum - request_size; +pub const Protocol = envelope.Define(.{ + .name = "display", + .version = 1, + .operations = &.{ + .{ .name = "info", .reply = Info }, + .{ .name = "create_layer", .request = CreateLayer, .reply = Created }, + .{ .name = "configure_layer", .request = ConfigureLayer }, + .{ .name = "destroy_layer" }, + .{ .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` /// (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, 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); +} diff --git a/library/protocol/input/input-protocol.zig b/library/protocol/input/input-protocol.zig index 885c60e..f14afe5 100644 --- a/library/protocol/input/input-protocol.zig +++ b/library/protocol/input/input-protocol.zig @@ -4,25 +4,29 @@ //! **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 -//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); they all travel in a common -//! `InputEvent` envelope tagged with a `DeviceKind`, so the fan-out path is one code path -//! 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. +//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); 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 -//! [VFS protocol](../vfs/protocol.zig): +//! Three shapes ride over the channel, and the envelope names all three +//! (docs/os-development/protocol-namespace.md): //! -//! - **subscribe / publish**: a synchronous `ipc_call` carrying a `Request`. `subscribe` -//! hands the service the subscriber's own endpoint as a capability (`send_cap`) and a -//! `device_mask`; `publish` carries an `InputEvent`. The reply is a `Reply`. -//! - **delivery**: the service pushes each `InputEvent` to every interested subscriber with -//! the asynchronous `ipc_send` — no reply owed, and a dead subscriber can never stall the -//! broadcast. Received in the subscriber's buffer with `Received.isMessage()` set. +//! - **subscribe** is the *reserved* verb, not one of this protocol's own: its shape — a +//! synchronous call whose attached capability is the subscriber's endpoint — is exactly +//! what `envelope.operation_subscribe` means everywhere. The interest mask travels as the +//! packet's tail (`Subscribe`), because a reserved verb carries no typed request. +//! - **publish** is this protocol's one verb: a source sends one `InputEvent` and the +//! 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` -//! client helpers, and every source/subscriber. Everything fits one IPC message. +//! `Header.target` is unused (0) in both directions: the service is the only object either +//! side addresses. 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 subscription mask. @@ -242,14 +246,17 @@ pub const JoystickEvent = extern struct { 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. 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 -/// matching per-device event. Decode it with `asKeyboard`/`asMouse`/`asJoystick` (each -/// returns null unless `device` matches), or build one with the `from*` constructors. +/// One event of any class: a `DeviceKind` plus the raw bytes of the matching per-device +/// event. This is what a source `publish`es (one verb for all three classes) and what a +/// 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 { device: u32, // a DeviceKind _padding: u32 = 0, @@ -285,35 +292,103 @@ pub const InputEvent = extern struct { } }; -// --- request / reply -------------------------------------------------------- +// --- the contract ----------------------------------------------------------- -/// Which side of a request this is. -pub const Operation = enum(u32) { - subscribe = 0, // register the caller's endpoint (send_cap) for the classes in device_mask - publish = 1, // a source submits `event` to broadcast to interested subscribers -}; +/// The body of a `subscribe` — the envelope's reserved verb 2, whose shape (a call whose +/// capability is the subscriber's own endpoint) this protocol adopts wholesale. A reserved +/// verb has no typed request, so the mask travels as the packet's tail and `encodeSubscribe` +/// 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 -/// wants (0 means all) and the caller's receive endpoint travels as the call's capability; -/// `event` is ignored. For `publish`, `event` is the event to broadcast. -pub const Request = extern struct { - operation: u32, // an Operation - device_mask: u32 = 0, // subscribe: interested device classes (0 => all) - event: InputEvent = .{ .device = 0 }, -}; +pub const Protocol = envelope.Define(.{ + .name = "input", + .version = 1, + .operations = &.{ + // A source submits one event; the service broadcasts it to whoever wants that class. + .{ .name = "publish", .request = InputEvent }, + }, + .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 Reply = extern struct { - status: i32, - _padding: u32 = 0, -}; - -pub const request_size: usize = @sizeOf(Request); -pub const reply_size: usize = @sizeOf(Reply); +pub const Operation = Protocol.Operation; +pub const Event = Protocol.Event; +pub const message_maximum: usize = Protocol.message_maximum; pub const event_size: usize = @sizeOf(InputEvent); -comptime { - // The delivery path posts a bare InputEvent through ipc_send, so it must fit an - // endpoint's async payload slot (POST_MAXIMUM is 64). - if (event_size > 64) @compileError("InputEvent must fit the ipc_send payload (POST_MAXIMUM)"); +/// The event class a `DeviceKind` value (as it appears in `InputEvent.device`) is delivered +/// as. Null for a value no class claims, which is delivered to nobody. +pub fn eventOfDevice(device: u32) ?Event { + 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); } diff --git a/library/protocol/scanout/scanout-protocol.zig b/library/protocol/scanout/scanout-protocol.zig index 7182e5c..fc88ddf 100644 --- a/library/protocol/scanout/scanout-protocol.zig +++ b/library/protocol/scanout/scanout-protocol.zig @@ -1,49 +1,55 @@ //! 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. -//! The driver owns the panel and the shared scanout surface it handed the compositor (via the -//! display service's `attach_scanout`); the compositor composites into that surface, then asks -//! the driver to present a damaged rectangle. Tiny by design — one present request. Separate -//! from 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. +//! virtio-gpu) over `/protocol/scanout` to put a composited frame on screen. The driver owns +//! the panel and the shared scanout surface it handed the compositor (via the display +//! service's `attach_scanout`); the compositor composites into that surface, then asks the +//! driver to present a damaged rectangle. Tiny by design — one present request. Separate from +//! the display protocol because the directions differ: clients call the compositor over +//! `/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(x, y, width, height): put the given rectangle of the shared scanout surface on - /// the panel (on virtio-gpu: transfer-to-host of the region, then a fenced resource flush). - 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, +/// `present(rect)`: put the given rectangle of the shared scanout surface on the panel (on +/// virtio-gpu: transfer-to-host of the region, then a fenced resource flush). +pub const Present = extern struct { x: u32 = 0, y: u32 = 0, width: u32 = 0, height: u32 = 0, }; -pub const Reply = extern struct { - status: i32, // 0 on success, negative on failure - reserved: u32 = 0, -}; +/// `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. +pub const SetMode = extern struct { width: u32, height: u32 }; /// One offered display mode. pub const Mode = extern struct { width: u32, height: u32 }; pub const max_modes = 4; -/// The reply to `get_modes`: a small fixed list of modes. -pub const ModesReply = extern struct { - status: i32, - count: u32, - modes: [max_modes]Mode, +/// The answer to `get_modes`: a small fixed list of modes. The success/failure verdict is +/// the reply's `Status`, so this carries only the modes. +pub const Modes = extern struct { + count: u32 = 0, + _padding: u32 = 0, + modes: [max_modes]Mode = @splat(.{ .width = 0, .height = 0 }), }; -pub const message_maximum: usize = 64; -pub const request_size: usize = @sizeOf(Request); -pub const reply_size: usize = @sizeOf(Reply); -pub const modes_reply_size: usize = @sizeOf(ModesReply); +pub const Protocol = envelope.Define(.{ + .name = "scanout", + .version = 1, + .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; diff --git a/library/protocol/vfs/vfs-protocol.zig b/library/protocol/vfs/vfs-protocol.zig index 67db6f7..020edd1 100644 --- a/library/protocol/vfs/vfs-protocol.zig +++ b/library/protocol/vfs/vfs-protocol.zig @@ -1,52 +1,27 @@ -//! The VFS wire protocol — the message format spoken between a client (via the file -//! API) and the user-space VFS server over IPC. A request is a fixed `Request` header -//! followed by an inline payload (a path, or write bytes); a reply is a fixed `Reply` -//! header followed by an inline payload (read bytes, or a FileStatus). Everything fits -//! in one IPC message (<= ipc MESSAGE_MAXIMUM = 256 bytes). +//! The VFS wire protocol — what a client (through the file API, +//! library/kernel/file-system.zig) says to a filesystem backend over IPC. Defined +//! through the envelope (docs/os-development/protocol-namespace.md), so every +//! packet begins with the folded `Header`: the verb in `Header.operation`, and +//! **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 -//! side is `runtime.fs` (library/runtime/fs.zig), which programs use directly. +//! This is a danos-native contract, so it uses danos names throughout. It is +//! 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. -//! 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 +//! **An `open` reply may carry a capability.** The `open` request rides //! `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 -//! with a node id and nothing else — but a *synthetic* backend does: opening a +//! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it — FAT answers with +//! a node id and nothing else — but the registry does: opening a //! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint, -//! which is the channel (docs/os-development/protocol-namespace.md). The -//! convention is per-backend, not per-operation: a client that did not ask a -//! synthetic backend simply gets no capability back, exactly as today. +//! which is the channel. The convention is per-backend, not per-operation: a +//! client that did not ask a synthetic backend simply gets no capability back. -pub const Operation = enum(u32) { - 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 -}; +const envelope = @import("envelope"); /// The type of a filesystem node, aligned to the node-kind table /// (docs/file-system-development/file-system-hierarchy.md). Fills `FileStatus.kind` and @@ -67,38 +42,18 @@ pub const NodeKind = enum(u32) { protocol = 7, }; -/// One directory entry, returned by `readdir`: a fixed header followed inline in -/// the reply payload by `name_len` bytes of name. A zero-length reply is EOF. +/// One directory entry: the fixed part of a `readdir` reply, followed inline by +/// `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 { - kind: u32, // a NodeKind - name_len: u32, - size: u64, + kind: u32 = 0, // a NodeKind + name_len: u32 = 0, + size: u64 = 0, }; 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 /// layer maps this onto `struct stat`. pub const FileStatus = extern struct { @@ -110,13 +65,84 @@ pub const FileStatus = extern struct { mtime: u64 = 0, }; -pub const message_maximum: usize = 256; -pub const request_size: usize = @sizeOf(Request); -pub const reply_size: usize = @sizeOf(Reply); -/// Largest inline payload that still fits one IPC message alongside a header. -pub const maximum_payload: usize = message_maximum - request_size; +// --- the per-operation request and reply parts ------------------------------ +// +// Each names the bytes AFTER the prefix. Nothing here carries an operation or a +// node id: those are the packet header's, folded in once. -/// 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; /// Open a directory (for readdir) rather than a file. A mounted backend uses /// 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. 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"); try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular)); 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, 7), @intFromEnum(NodeKind.protocol)); 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)); - try std.testing.expectEqual(@as(u32, 4), @intFromEnum(Operation.status)); - try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir)); - try std.testing.expectEqual(@as(u32, 10), @intFromEnum(Operation.rename)); - // The registry's claim verb, appended last with the protocol namespace. - try std.testing.expectEqual(@as(u32, 11), @intFromEnum(Operation.bind)); + + // The numbering the envelope gives this protocol. These are NEW values: the + // rebase moved every verb above the reserved range, so the old 0..11 are + // gone and 16..27 are what the wire carries. Pinned because both sides of a + // flag-day have to agree on them, not because they may never change again. + try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.open)); + 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); } diff --git a/system/abi.zig b/system/abi.zig index ce63bec..9953f98 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -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_backend: u64 = 1; // rdx = endpoint handle; speak vfs-protocol -/// fs_node operations — the same numbers as the vfs-protocol Operation enum, so -/// client code shares one vocabulary. +/// fs_node operations. These were once the vfs-protocol Operation numbers; the +/// 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_status: u64 = 4; pub const fs_node_readdir: u64 = 5; diff --git a/system/configuration/protocol.csv b/system/configuration/protocol.csv index 57fee6f..cb0601d 100644 --- a/system/configuration/protocol.csv +++ b/system/configuration/protocol.csv @@ -148,6 +148,16 @@ /test/system/services/input-source, 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 # 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 diff --git a/system/drivers/usb-storage/build.zig b/system/drivers/usb-storage/build.zig index ae8cb90..9fcd217 100644 --- a/system/drivers/usb-storage/build.zig +++ b/system/drivers/usb-storage/build.zig @@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void { .name = "usb-storage", .root_source_file = b.path("usb-storage.zig"), .imports = &.{ - "block-protocol", "driver", "ipc", "logging", "memory", "process", "service", - "time", "usb", + "block-protocol", "driver", "envelope", "ipc", "logging", "memory", "process", + "service", "time", "usb", }, }); b.installArtifact(exe); diff --git a/system/drivers/usb-storage/usb-storage.zig b/system/drivers/usb-storage/usb-storage.zig index 6bb5f75..5db245e 100644 --- a/system/drivers/usb-storage/usb-storage.zig +++ b/system/drivers/usb-storage/usb-storage.zig @@ -22,8 +22,16 @@ const logging = @import("logging"); const usb = @import("usb"); const scsi = @import("scsi.zig"); const bot = @import("bulk-only-transport.zig"); +const envelope = @import("envelope"); 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: usb.Device = undefined; var bulk_in: usb.Endpoint = undefined; @@ -144,53 +152,65 @@ fn initialise(endpoint: ipc.Handle) bool { return true; } -/// Serve the block protocol: geometry, and whole-block read/write to/from the -/// caller's DMA buffer (named by physical address). -fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { - _ = sender; - if (message.len < block_protocol.request_size) return 0; - const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]); - switch (request.operation) { - @intFromEnum(block_protocol.Operation.attach) => { - // 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 above it alike. - const handle = arrived.peek() orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 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, - } +// --- serving the block protocol --------------------------------------------- +// +// Geometry, and whole-block read/write to and from the caller's DMA buffer +// (named by physical address). One device per process, so `Header.target` is +// always 0 and no handler reads it. + +/// A transfer the device refused. Every failure here is the same one — the SCSI +/// command did not complete — so there is one errno for all of them. +const refused: isize = -envelope.ENOENT; + +fn onGeometry(_: void, _: Invocation(void), answer: Answer(block_protocol.Geometry)) isize { + answer.set(.{ .block_size = block_size, .block_count = block_count }); + return 0; } -fn writeReply(reply: []u8, value: block_protocol.Reply) usize { - const bytes = std.mem.asBytes(&value); - @memcpy(reply[0..bytes.len], bytes); - return bytes.len; +fn onRead(_: void, invocation: Invocation(block_protocol.Transfer), answer: Answer(block_protocol.Transferred)) isize { + const request = invocation.request; + const cdb = scsi.read10(@intCast(request.lba), @intCast(request.count)); + 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 { diff --git a/system/drivers/virtio-gpu/build.zig b/system/drivers/virtio-gpu/build.zig index 74e2ba5..eff5518 100644 --- a/system/drivers/virtio-gpu/build.zig +++ b/system/drivers/virtio-gpu/build.zig @@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void { .name = "virtio-gpu", .root_source_file = b.path("virtio-gpu.zig"), .imports = &.{ - "channel", "display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", - "process", "scanout-protocol", "service", "time", + "channel", "display-protocol", "driver", "envelope", "ipc", "logging", "memory", + "mmio", "pci", "process", "scanout-protocol", "service", "time", }, }); b.installArtifact(exe); diff --git a/system/drivers/virtio-gpu/virtio-gpu.zig b/system/drivers/virtio-gpu/virtio-gpu.zig index 4f5af7c..33d16a5 100644 --- a/system/drivers/virtio-gpu/virtio-gpu.zig +++ b/system/drivers/virtio-gpu/virtio-gpu.zig @@ -25,11 +25,19 @@ const memory = @import("memory"); const logging = @import("logging"); const mmio = @import("mmio"); const pci = @import("pci"); +const envelope = @import("envelope"); const display_protocol = @import("display-protocol"); const scanout_protocol = @import("scanout-protocol"); const vp = @import("virtio-pci.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 compositor in the announce so it packs colours in the surface's byte order. const display_format_bgrx: u32 = 1; @@ -482,60 +490,60 @@ fn announce() void { std.log.info("no display service to announce to (scanout-only)", .{}); return; }; - var request = display_protocol.Request{ - .operation = @intFromEnum(display_protocol.Operation.attach_scanout), - .x = 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 + var packet: [display_protocol.message_maximum]u8 = undefined; + const framed = display_protocol.Protocol.encodeRequest(.attach_scanout, 0, .{ + .stride = max_width, // the shared surface's row stride in pixels (it is sized to the max mode) .width = current_width, .height = current_height, - .colour = display_format_bgrx, - }; - var reply: [display_protocol.reply_size]u8 = undefined; - _ = ipc.callCap(display, std.mem.asBytes(&request), &reply, surface.handle) catch { + .format = display_format_bgrx, + .refresh_hz = edid_refresh_hz, // from EDID (0 = unknown) — the frame-clock seed + }, &.{}, &packet) orelse return; + var reply: [display_protocol.message_maximum]u8 = undefined; + _ = ipc.callCap(display, framed, &reply, surface.handle) catch { std.log.info("announce to display failed", .{}); return; }; std.log.info("announced scanout to display", .{}); } -/// A `scanout_protocol.Reply{status}` written into `reply`. -fn scanoutStatus(reply: []u8, ok: bool) usize { - const response = scanout_protocol.Reply{ .status = if (ok) 0 else -1 }; - @memcpy(reply[0..scanout_protocol.reply_size], std.mem.asBytes(&response)); - return scanout_protocol.reply_size; +// --- serving the scanout protocol ------------------------------------------- +// +// The compositor drives present / mode queries here. The pixels are already in +// the shared surface, so a present is a transfer-to-host + fenced flush; a mode +// 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 -/// already in the shared surface, so a present is a transfer-to-host + fenced flush; a mode -/// change just re-points the scanout rectangle (the surface is sized to the largest mode). -fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { - _ = 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, +fn onGetModes(_: void, _: Invocation(void), answer: Answer(scanout_protocol.Modes)) isize { + var offered = scanout_protocol.Modes{ .count = offered_modes.len }; + for (0..@min(offered_modes.len, scanout_protocol.max_modes)) |i| { + offered.modes[i] = .{ .width = offered_modes[i].width, .height = offered_modes[i].height }; } + 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 { @@ -547,7 +555,7 @@ pub fn main(init: process.Init) void { std.log.info("malformed device id '{s}'", .{argument}); return; }; - service.run(256, .{ + service.run(scanout_protocol.message_maximum, .{ .service = "scanout", .init = initialise, .on_message = onMessage, diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 9d91dd3..4758511 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -250,6 +250,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { protocolRegistryTest(boot_information); } else if (eql(case, "protocol-denied")) { protocolDeniedTest(boot_information); + } else if (eql(case, "protocol-conformance")) { + protocolConformanceTest(boot_information); } else if (eql(case, "reboot")) { rebootTest(); } else { @@ -3898,6 +3900,68 @@ fn protocolDeniedTest(boot_information: *const BootInformation) void { 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 { log("DANOS-TEST-BEGIN: device-manager\n", .{}); if (boot_information.initial_ramdisk_len == 0) { diff --git a/system/services/display/backend.zig b/system/services/display/backend.zig index 5c69982..182fea5 100644 --- a/system/services/display/backend.zig +++ b/system/services/display/backend.zig @@ -13,6 +13,7 @@ const memory = @import("memory"); const logging = @import("logging"); const compositor = @import("compositor.zig"); +const envelope = @import("envelope"); const scanout_protocol = @import("scanout-protocol"); const Rect = compositor.Rect; const Surface = compositor.Surface; @@ -188,45 +189,53 @@ pub const VirtioGpu = struct { /// accumulated; the driver transfers + fenced-flushes the whole frame. pub fn present(self: *const VirtioGpu, damage: []const Rect) void { _ = damage; - var request = scanout_protocol.Request{ - .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 {}; + _ = call(self.scanout, .present, .{ .width = self.width, .height = self.height }); } /// Fill `out` with the driver's offered modes; returns how many were written. pub fn modes(self: *const VirtioGpu, out: []Mode) usize { - var request = scanout_protocol.Request{ .operation = @intFromEnum(scanout_protocol.Operation.get_modes) }; - var reply: [scanout_protocol.modes_reply_size]u8 = undefined; - const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return 0; - if (n < scanout_protocol.modes_reply_size) return 0; - 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]; + const answered = call(self.scanout, .get_modes, {}) orelse return 0; + const offered = Scanout.decodeReply(.get_modes, answered.packet[0..answered.len]) orelse return 0; + const count = @min(@min(offered.count, scanout_protocol.max_modes), out.len); + for (0..count) |i| out[i] = offered.modes[i]; return count; } /// 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). pub fn setMode(self: *VirtioGpu, w: u32, h: u32) bool { if (w == 0 or h == 0 or w > self.stride) return false; - var request = scanout_protocol.Request{ - .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; + _ = call(self.scanout, .set_mode, .{ .width = w, .height = h }) orelse return false; self.width = w; self.height = h; 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 /// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4. pub const Backend = union(enum) { diff --git a/system/services/display/build.zig b/system/services/display/build.zig index 1185086..93ac232 100644 --- a/system/services/display/build.zig +++ b/system/services/display/build.zig @@ -10,8 +10,9 @@ pub fn build(b: *std.Build) void { .name = "display", .root_source_file = b.path("display.zig"), .imports = &.{ - "channel", "display-client", "display-protocol", "driver", "input-client", "ipc", - "logging", "memory", "scanout-protocol", "service", "thread", "time", + "channel", "display-client", "display-protocol", "driver", "envelope", "input-client", + "ipc", "logging", "memory", "scanout-protocol", "service", + "thread", "time", }, .threaded = true, // real atomics/TLS (docs/threading.md) }); diff --git a/system/services/display/display.zig b/system/services/display/display.zig index cdae4e2..780d136 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -28,10 +28,22 @@ const logging = @import("logging"); const compositor = @import("compositor.zig"); const backend_mod = @import("backend.zig"); +const envelope = @import("envelope"); const display_protocol = @import("display-protocol"); const Rect = compositor.Rect; 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 /// (virtio-gpu) when one announces itself (V4). var backend: backend_mod.Backend = undefined; @@ -314,11 +326,18 @@ fn verifyNativePresent() void { /// `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 /// per restart. -fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, arrived: *ipc.Arrival, reply: []u8) usize { - const cap = arrived.peek() orelse return fail(reply); - if (width == 0 or height == 0 or stride < width) return fail(reply); - const mapped = memory.sharedMap(cap) orelse return fail(reply); - const scanout = channel.openEndpoint("scanout") orelse return fail(reply); +fn onAttachScanout(_: void, invocation: Invocation(display_protocol.AttachScanout), _: Answer(void)) isize { + const announce = invocation.request; + const stride = announce.stride; + const width = announce.width; + 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 // 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.) @@ -346,7 +365,7 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: "display: scanout re-attached\n" else "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 @@ -609,88 +628,109 @@ fn initialise(endpoint: ipc.Handle) bool { return true; } -fn writeReply(reply: []u8, value: display_protocol.Reply) usize { - const bytes = std.mem.asBytes(&value); - @memcpy(reply[0..bytes.len], bytes); - return bytes.len; +// --- the protocol handlers -------------------------------------------------- +// +// A layer id is `Header.target` on every verb that names one, so no handler +// 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 { - return writeReply(reply, .{ .status = 0 }); +fn onInfo(_: void, _: Invocation(void), answer: Answer(display_protocol.Info)) isize { + 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 { - return writeReply(reply, .{ .status = -1 }); +fn onCreateLayer(_: void, invocation: Invocation(display_protocol.CreateLayer), answer: Answer(display_protocol.Created)) isize { + 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 { - _ = sender; - if (message.len < display_protocol.request_size) return fail(reply); - const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]); - const payload = message[display_protocol.request_size..]; - // Switch on the raw operation value — an out-of-range one must fail cleanly, not - // 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), - } + // The one capability this service is ever handed is the scanout driver's + // shared surface, and `attachScanout` deliberately does not claim it (the + // mapping holds its own reference) — so the capability is peeked, never + // taken, and the turn closes it. + return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply); } /// Two notification sources reach the compositor, and one coalesced badge can carry diff --git a/system/services/fat/build.zig b/system/services/fat/build.zig index 5051929..1df42bb 100644 --- a/system/services/fat/build.zig +++ b/system/services/fat/build.zig @@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void { .name = "fat", .root_source_file = b.path("fat.zig"), .imports = &.{ - "block", "file-system", "ipc", "logging", "memory", "process", "service", "time", - "vfs-protocol", + "block", "envelope", "file-system", "ipc", "logging", "memory", "process", + "service", "time", "vfs-protocol", }, }); b.installArtifact(exe); diff --git a/system/services/fat/fat.zig b/system/services/fat/fat.zig index 93e53e3..e266a27 100644 --- a/system/services/fat/fat.zig +++ b/system/services/fat/fat.zig @@ -20,8 +20,17 @@ const memory = @import("memory"); const logging = @import("logging"); const engine = @import("engine.zig"); const on_disk = @import("on-disk.zig"); +const envelope = @import("envelope"); 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"; // 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; } -fn writeReply(out: []u8, reply: vfs_protocol.Reply, payload: []const u8) usize { - @memcpy(out[0..vfs_protocol.reply_size], std.mem.asBytes(&reply)); - const n = @min(payload.len, out.len - vfs_protocol.reply_size); - @memcpy(out[vfs_protocol.reply_size..][0..n], payload[0..n]); - return vfs_protocol.reply_size + n; -} - -fn fail(out: []u8) usize { - return writeReply(out, .{ .status = -1 }, &.{}); -} +/// What a handler returns when the thing asked for is not there — a bad node id, +/// a path that does not resolve, a mutation the volume refused. One errno for all +/// of them, because a filesystem's failures are all "no such thing" as far as the +/// file API can act on them. +const refused: isize = -envelope.ENOENT; /// 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), @@ -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); if (node == null and flags & vfs_protocol.create != 0) { 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); } - 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 // (frees the old chain, so a shorter rewrite leaves no stale tail). if (flags & vfs_protocol.truncate != 0 and !resolved.is_directory) { filesystem.truncate(&resolved); } - const index = allocOpen() orelse return fail(out); - open_nodes[index] = .{ .used = true, .node = resolved, .owner = sender }; - return writeReply(out, .{ .status = 0, .node = index }, &.{}); + const index = allocOpen() orelse return refused; + open_nodes[index] = .{ .used = true, .node = resolved, .owner = invocation.sender }; + 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 /// 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 @@ -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. fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = arrived; - if (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry - if (message.len < vfs_protocol.request_size) return fail(out); - const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]); - const payload = message[vfs_protocol.request_size..]; - + // Storage not up (yet): fail politely, whatever was asked — clients retry. + if (!mounted) { + const status = envelope.Status{ .status = refused, .len = 0 }; + @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 // keeps the engine pure (it takes the time as data, not a syscall). filesystem.current_time_epoch = time.wallClock(); - - 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), - } + return Serve.dispatch({}, handlers, message, sender, null, out); } pub fn main() void { diff --git a/system/services/init/init.zig b/system/services/init/init.zig index fcef455..43ba009 100644 --- a/system/services/init/init.zig +++ b/system/services/init/init.zig @@ -548,34 +548,43 @@ var heartbeat_running = false; /// `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. fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *Arrival) usize { - if (request_bytes.len < vfs_protocol.request_size) - return answer(reply, -envelope.EPROTO, 0, 0); - // The header is read field by field rather than reinterpreted whole: the - // operation is an enum on the wire and the bytes come from anyone at all, so - // a value outside it must be a refusal, never a decoded enum. + if (request_bytes.len < envelope.prefix_size) + return answer(reply, -envelope.EPROTO, 0); + // The header is read field by field rather than reinterpreted whole, and the + // verb is compared as a number rather than decoded into the generated + // `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 cursor = std.mem.readInt(u64, request_bytes[16..24], .little); - 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]; + const body = request_bytes[envelope.prefix_size..]; 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 // 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. - if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, sender, payload); - if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor); + if (operation == @intFromEnum(vfs_protocol.Operation.open)) { + // `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 // 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). -fn answer(reply: []u8, status: i32, node: u64, payload_len: usize) usize { - const header = vfs_protocol.Reply{ .status = status, .node = node, .len = @intCast(payload_len) }; - @memcpy(reply[0..vfs_protocol.reply_size], std.mem.asBytes(&header)); - return vfs_protocol.reply_size + payload_len; +/// Lay down the envelope's reply prefix (and say how many payload bytes the +/// caller has already written after it). +fn answer(reply: []u8, status: i32, payload_len: usize) usize { + const header = envelope.Status{ .status = status, .len = @intCast(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 @@ -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 /// milliseconds.) 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(); const identity = identify(sender); const permitted = if (identity) |who| mayOpen(who, name) else false; const binding = findBinding(name); - if (!permitted) return answer(reply, -envelope.ENOENT, 0, 0); - const found = binding orelse 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); 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 @@ -690,18 +703,25 @@ fn onReaddir(reply: []u8, cursor: u64) usize { continue; } const name = binding.nameSlice(); - const entry = vfs_protocol.DirectoryEntry{ + return writeEntry(reply, .{ .kind = @intFromEnum(vfs_protocol.NodeKind.protocol), .name_len = @intCast(name.len), .size = binding.task, - }; - 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); + }, name); } - 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 { diff --git a/system/services/input/build.zig b/system/services/input/build.zig index 8c33a3d..ce9499b 100644 --- a/system/services/input/build.zig +++ b/system/services/input/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "input", .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); } diff --git a/system/services/input/input.zig b/system/services/input/input.zig index 74ced4b..70cadac 100644 --- a/system/services/input/input.zig +++ b/system/services/input/input.zig @@ -17,17 +17,29 @@ //! //! 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 -//! 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 channel = @import("channel"); +const envelope = @import("envelope"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); -const input = @import("input-client"); const logging = @import("logging"); 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 /// 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. @@ -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. /// `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 { - 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); 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 -/// capability the request came with, under the same ownership rule the service harness -/// states (`ipc.Arrival`): **it belongs to the turn, and only a handler that means to keep -/// it says `take`.** Everything else here — a short message, a `publish`, a subscribe that -/// finds the table full — simply returns, and the loop closes what arrived. Writes a -/// `Reply` into `out` and returns its length. -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; - } - }; +/// Set by `onSubscribe` when the subscriber table has taken ownership of the capability the +/// call carried, and read by `onMessage`, which is where the turn's `Arrival` lives. The +/// generated dispatch hands a handler the raw handle rather than the `Arrival` — deliberately, +/// since a handler has no business closing the turn's property — so the *claim* has to travel +/// back out this way. One turn, one handler, one thread: there is nothing here to race. +var capability_claimed = false; - if (message.len < input_protocol.request_size) return reply.write(out, -1); - const request = std.mem.bytesToValue(input_protocol.Request, message[0..input_protocol.request_size]); +/// The reserved `subscribe` verb: register the caller's endpoint (the call's capability) for +/// 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))) { - .subscribe => { - const endpoint = arrived.peek() orelse return reply.write(out, -1); // no endpoint passed - // 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(); - 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 - return reply.write(out, 0); - }, - .publish => { - broadcast(request.event); - return reply.write(out, 0); - }, - } +fn onPublish(_: void, invocation: Invocation(input_protocol.InputEvent), _: Answer(void)) isize { + broadcast(invocation.request); + return 0; +} + +const handlers = Serve.Handlers{ .publish = onPublish, .subscribe = onSubscribe }; + +fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize { + capability_claimed = false; + const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), out); + if (capability_claimed) _ = arrived.take(); + return written; +} + +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 { - const endpoint = ipc.createIpcEndpoint() orelse { - _ = logging.write("/system/services/input: no endpoint\n"); - return; - }; - 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); - } + service.run(input_protocol.message_maximum, .{ + .service = "input", + .init = initialise, + .on_message = onMessage, + }); } diff --git a/test/qemu_test.py b/test/qemu_test.py index ed37da5..a273b77 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -889,6 +889,25 @@ CASES = [ r"(?=.*protocol-denied: ok)" r"(?=.*DANOS-TEST-RESULT: PASS)", "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 # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn # -> driver-up (the spawned pci-bus logs " functions found"). diff --git a/test/system/services/protocol-conformance-test/build.zig b/test/system/services/protocol-conformance-test/build.zig new file mode 100644 index 0000000..0040ed2 --- /dev/null +++ b/test/system/services/protocol-conformance-test/build.zig @@ -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); +} diff --git a/test/system/services/protocol-conformance-test/build.zig.zon b/test/system/services/protocol-conformance-test/build.zig.zon new file mode 100644 index 0000000..e709188 --- /dev/null +++ b/test/system/services/protocol-conformance-test/build.zig.zon @@ -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 = .{""}, +} diff --git a/test/system/services/protocol-conformance-test/protocol-conformance-test.zig b/test/system/services/protocol-conformance-test/protocol-conformance-test.zig new file mode 100644 index 0000000..e5dade4 --- /dev/null +++ b/test/system/services/protocol-conformance-test/protocol-conformance-test.zig @@ -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(); +} diff --git a/test/system/services/protocol-denied-test/protocol-denied-test.zig b/test/system/services/protocol-denied-test/protocol-denied-test.zig index 7e0942b..4976ce0 100644 --- a/test/system/services/protocol-denied-test/protocol-denied-test.zig +++ b/test/system/services/protocol-denied-test/protocol-denied-test.zig @@ -95,9 +95,13 @@ const Answer = struct { /// Whether a capability rode the reply. The one field that actually matters /// to a client: the capability IS the channel. capability: bool = false, - /// The reply header, decoded — compared field by field as well as byte for - /// byte, so a failure says *which* field diverged. - reply: vfs_protocol.Reply = .{ .status = 0, .node = 0, .len = 0 }, + /// The reply's envelope `Status`, decoded — compared field by field as well + /// as byte for byte, so a failure says *which* field diverged. + 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 { return self.packet[0..self.length]; @@ -129,33 +133,30 @@ fn registryEndpoint() ?ipc.Handle { const resolve_attempts: u32 = 200; const resolve_retry_ms: u64 = 20; -/// One vfs-protocol request at the registry: the fixed header, then the contract -/// name inline. Names go bare (`input`, not `/input`) — the registrar normalises -/// both, and bare is what `bind` sends. -fn transact(registry: ipc.Handle, operation: vfs_protocol.Operation, name: []const u8, cursor: u64) ?Answer { - var request: [vfs_protocol.message_maximum]u8 = undefined; - if (vfs_protocol.request_size + name.len > request.len) return null; - const header = vfs_protocol.Request{ - .operation = operation, - .node = 0, - .offset = cursor, - .len = @intCast(name.len), - .flags = 0, - }; - @memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header)); - @memcpy(request[vfs_protocol.request_size..][0..name.len], name); +/// One vfs-protocol request at the registry: the folded header, the verb's own +/// fixed part, then the contract name as the packet's tail. Names go bare +/// (`input`, not `/input`) — the registrar normalises both, and bare is what +/// `bind` sends. +fn transact( + registry: ipc.Handle, + comptime operation: vfs_protocol.Operation, + request: vfs_protocol.Protocol.RequestOf(operation), + name: []const u8, +) ?Answer { + var packet: [vfs_protocol.message_maximum]u8 = undefined; + const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null; var answer: Answer = .{}; - const got = ipc.callCap( - registry, - request[0 .. vfs_protocol.request_size + name.len], - &answer.packet, - null, - ) catch return null; - if (got.len < vfs_protocol.reply_size) return null; + const got = ipc.callCap(registry, framed, &answer.packet, null) catch return null; answer.length = got.len; 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 // below only care that one arrived. 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 /// all — a registrar that answered has decided, and its decision is the subject. 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 @@ -177,12 +178,11 @@ fn openContract(registry: ipc.Handle, name: []const u8) Answer { fn listed(registry: ipc.Handle, name: []const u8) bool { var cursor: u64 = 0; while (cursor < 64) : (cursor += 1) { - const answer = transact(registry, .readdir, "", cursor) orelse return false; - if (answer.reply.status != 0 or answer.reply.len == 0) return false; // end of directory - const payload = answer.packet[vfs_protocol.reply_size..answer.length]; - if (payload.len < vfs_protocol.directory_entry_size) return false; - const entry = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, payload[0..vfs_protocol.directory_entry_size]); - const text = payload[vfs_protocol.directory_entry_size..]; + const answer = transact(registry, .readdir, .{ .cursor = cursor }, "") orelse return false; + if (answer.status.status != 0) return false; + const entry = vfs_protocol.Protocol.decodeReply(.readdir, answer.bytes()) orelse return false; + if (entry.name_len == 0) return false; // end of directory + const text = vfs_protocol.Protocol.replyTail(.readdir, answer.bytes()); const length = @min(@as(usize, entry.name_len), text.len); 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 /// a failure says which comparison broke and in which field. fn expectIdentical(step: []const u8, refused: Answer, absent: Answer) void { - if (refused.reply.status != absent.reply.status) fail(step); // the errno - if (refused.reply.node != absent.reply.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.status != absent.status.status) fail(step); // the errno + if (!nodesMatch(refused.node, absent.node)) fail(step); // the node id an open would return + if (refused.status.len != absent.status.len) fail(step); // payload bytes promised if (refused.length != absent.length) fail(step); // reply packet length 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 } +/// 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 { 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 // capability that IS the channel. 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"); _ = logging.write("protocol-denied: granted open succeeded\n"); // 2. The refusal. `input` is bound — the listing above proved it — and no // manifest row names this binary against it. 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"); _ = 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 // that had wedged. 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"); }