library: five protocols speak the envelope

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

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

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

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

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

View File

@ -340,6 +340,7 @@ pub fn build(b: *std.Build) void {
"user-memory-test", // aims deliberately bad user pointers at the checked copy layer
"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");

View File

@ -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 <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding

View File

@ -106,8 +106,10 @@ The bring-up sequence mirrors a hardware driver's — it is the
[`usb-xhci-bus` `initialise`](../../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
(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

View File

@ -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

View File

@ -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 (016 bytes), then
an inline tail of at most **224 bytes** (`maximum_payload`) — a path, or
write bytes. There is no multi-message request: paths and single
reads/writes must fit, and larger transfers loop (see *read* / *write*).
- A reply is the status, then the verb's own fixed part, then an inline tail
— read bytes, or a directory entry's name.
- All integers are **little-endian**; layouts are C layout for x86-64
(`extern struct`), offsets given below so nothing need be inferred.
@ -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); 015 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 015 are the envelope's reserved universal
verbs, which mean the same thing at every provider in the system: `describe`
(0) answers the protocol's name and version and is implemented by the
envelope itself, so every backend answers it. A verb outside this table is
answered `-ENOSYS`; it is never a safety check any more, because the
dispatch compares numbers rather than decoding an enum.
| 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` 01 and 67, `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` 01 and 67, `Operation` values 1621, 26
and 27); this page is the full record of the frozen values.
*The one renumbering this contract has had was the rebase onto the envelope
(P4a), which moved every verb above the reserved range — a deliberate
flag-day across a system with no third-party clients yet, not a precedent.*
- The 256-byte message ceiling is a property of the current IPC transport,
not a promise; clients should read `maximum_payload`-shaped limits from the
reply lengths they actually get (loop-until-done), not hard-code 224.
- 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.

View File

@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.**
| | |
|---|---|
| Working on | **P4a** — protocol rebase onto `envelope.Define` |
| 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

View File

@ -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") },

View File

@ -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 };
}

View File

@ -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.

View File

@ -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];
}
};

View File

@ -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") },

View File

@ -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):

View File

@ -192,41 +192,37 @@ fn reach(path: []const u8) ?Registry {
}
}
/// One vfs-protocol round trip at a backend: fixed header, inline payload, and
/// 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

View File

@ -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`;

View File

@ -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;

View File

@ -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);

View File

@ -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, <inline pixels>): copy a small pixel tile in.
blit_tile = 5,
/// damage(layer, x, y, width, height): mark a region dirty for the next present.
damage = 6,
/// present(): composite the dirty layers and flush to the screen.
present = 7,
/// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
/// capability>: a native scanout driver announces itself, handing over the shared scanout
/// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
/// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
/// `x` is the surface's row stride in pixels, `y` the panel refresh rate from the
/// driver's EDID read (0 = unknown; paces the compositor's frame clock), `colour` the
/// DisplayFormat.
attach_scanout = 8,
/// set_mode(width, height): change the display resolution only a native backend that
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
set_mode = 9,
/// get_modes() -> ModesReply: the resolutions the display can switch to (empty on GOP).
get_modes = 10,
};
/// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels)
/// follows this header inline in the same message, up to `maximum_payload`.
pub const Request = extern struct {
operation: u32,
layer: u32 = 0, // create/configure/destroy/fill/blit/damage: the target layer
x: u32 = 0,
y: u32 = 0,
/// 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);
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}

View File

@ -252,8 +252,11 @@ pub const klog_maximum_message: usize = 256;
pub const fs_route_kernel: u64 = 0; // rdx = node token; serve via fs_node
pub const fs_route_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;

View File

@ -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

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

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "usb-storage",
.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);

View File

@ -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 {

View File

@ -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);

View File

@ -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,

View File

@ -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) {

View File

@ -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) {

View File

@ -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)
});

View File

@ -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

View File

@ -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);

View File

@ -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 {

View File

@ -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 {

View File

@ -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);
}

View File

@ -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,
});
}

View File

@ -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 "<N> functions found").

View File

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

View File

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

View File

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

View File

@ -95,9 +95,13 @@ const Answer = struct {
/// Whether a capability rode the reply. The one field that actually matters
/// 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");
}