Author SHA1 Message Date
Daniel Samson 1b1c587c14 library: the harness keeps the subscribers, and an id belongs to whoever opened it
Three services had each written the same thing and got it three different
ways: input polled the process list to notice a dead subscriber, and only
when someone else subscribed; the power service never noticed at all; the
device manager noticed drivers but not subscribers. The harness owns the
table now, driven by the events a protocol declares — it registers on the
reserved verb, frames each event once, posts to everyone interested without
waiting on any of them, and reclaims a slot when the kernel says its owner
died. Interest masks moved to the envelope, so a subscriber that wants only
mice asks the same way everywhere.

Two consequences the plan had not foreseen. The device manager now hears a
supervised child's death twice, once as its supervisor and once as a
subscriber, so restart backoff counted every crash twice and gave up after
half as many; it retires the id before counting. And the kernel's published
exit table had eight slots for what is now six subscriptions in a plain
boot, so it holds sixteen.

The other half is a hole the design named early and left standing: a
backend handed out a small integer and then honoured it from anyone. A
process that guessed a file's node id read another client's file; a display
layer had no owner at all, so any client could reconfigure or destroy any
layer; a USB device token was never checked against the client that opened
it. Each is now bound to the task that opened it, and a wrong owner gets
exactly what an unknown id gets — the refusal must not become the oracle
the identical answers elsewhere were designed to remove. Closing a file
changed with it: it used to succeed unconditionally, which would have told
a caller which ids existed.

Suite 111/111, with a new case in which one process holds a file and a
layer, hands both ids to a second process, and finds them untouched after
that process has tried everything with them.
2026-08-01 09:05:26 +01:00
Daniel Samson 2719b93530 library: the last three protocols speak the envelope
These were the awkward ones. Each began with an operation packed into a
single byte — two of them with a version wedged in beside it — so there was
no wrapping them: the layouts had to be rebuilt. The device manager's own
enumerate and subscribe become the reserved verbs that mean the same thing
everywhere, its replies lose three status structs the envelope already
carries, and a device id becomes the packet's target. Power drops the
version it repeated on every request, because describe is the handshake,
and stops claiming a 64-byte ceiling it never needed for calls. USB moves a
control transfer's data to the packet tail in both directions, which makes
the status length the transferred length and retires a field that had been
saying the same thing twice.

The danger in this one was not the protocols but their readers. Init
recognised a power button by two bytes at the head of a message, the ACPI
service dispatched on the first byte, the xHCI driver read its operation
with a raw integer load, and the HID drivers reinterpreted a report
wholesale — none of which would have failed to compile once the layouts
moved. They would simply have stopped: no shutdown on the power button, no
reports from the keyboard. Every one of them now reads through the
generated types, and the shutdown gate that answers only a subscriber is
the same code it was.

Two sizes were decided by measuring rather than assuming. The child-added
message is both a request and the event broadcast to subscribers, and
alignment rounds it to 48 bytes, which puts its packet exactly on the
64-byte push floor — a test pins that, because a field added carelessly
would now overflow it. The interrupt report gives up eight bytes of inline
room to make space for the header; the two drivers that produce reports
send eight and four.

Suite 110/110.
2026-08-01 07:20:37 +01:00
Daniel Samson d2dfbcabf8 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.
2026-08-01 06:15:25 +01:00
Daniel Samson b004b9c3eb merge: security track group 2 — the protocol namespace replaces ServiceId
# Conflicts:
#	docs/security-track-plan.md
2026-08-01 04:45:14 +01:00
Daniel Samson 3e6e21bf0a docs: security track group 2 merged 2026-08-01 04:44:57 +01:00
Daniel Samson 0fbd2c8f12 init: a protocol you were not granted does not exist
The registry consults the open rows it has been parsing since P2, so
reaching a contract now takes a grant as well as a binding. A caller
without one is answered exactly as it would be for a name nobody ever
bound: same status, same empty reply, same absent capability, byte for
byte, and no log line on either path — klog_read is ungated, so a line on
one and not the other would be the oracle the design set out to remove.
Refusal and absence being one answer is what lets a supervisor later
narrow, fake or park a child's namespace without the child learning what
it was denied.

The manifest gains a third permission for a shape the plan did not
foresee: attestation is one hop, but the driver tree is three deep — the
PS/2 keyboard and mouse are spawned by ps2-bus, which the device manager
spawned — so no row could name them and PS/2 input would simply stop.
A supervise grant lets a delegate vouch for what its children *reach*,
never for what they claim; the bind path is untouched, and the laundering
deputy is still refused.

The review found the receive side of a rule this track had already
written down. Every process holds a sendable handle to the registrar —
resolve installs one for anyone who asks — and ipc_reply_wait never asked
who owned the endpoint, so a stranger could dequeue there: take the
provider endpoints riding bind requests, and answer other clients' opens
in the registrar's name. Receiving is the owner's privilege, like binding
a signal or a timer; sending remains anyone's.

Suite 109/109.
2026-08-01 04:44:45 +01:00
Daniel Samson 73aea4582b docs: the security track plan carries its live state on main
The plan is the only progress view from a plain main checkout, and the work
lands on a feature branch that reaches main a group at a time — so between
merges the file said less than was true. It now opens with where the work
is, which branch carries it, and what main itself holds, refreshed on main
after every phase rather than at merges alone.
2026-08-01 04:01:39 +01:00
Daniel Samson 1379b699f3 init: /protocol replaces the ServiceId registry
A protocol is reached by name now, not by a compile-time integer. Init is
PID 1 and already knows which binary it started, so init serves /protocol
as a vfs backend: bind claims a contract with the provider's endpoint
attached, open answers with that endpoint as the reply's capability, and
readdir lists what is bound with the task and binary behind it. The kernel
reserves the prefix — nothing may mount over it, under it, or unmount it —
and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers
left vacant.

A bind is authorized by who the caller *is*: the kernel-stamped binary
together with the supervising task's identity, matched against
/system/configuration/protocol.csv. Identity, not spelling — spawn is
ungated, so an attacker can run any bundled binary, and a name-only rule
would have let it launder grants through an init of its own making. A name
a live process holds is refused to everyone else; a dead one's is released.

Three review rounds against a hostile ring-3 process found what 108 green
tests could not, because the suite contains no attacker. Publishing init's
supervision endpoint as the registry put PID 1's mailbox in every process's
hands, where two forged bytes reached the shutdown path: privileged traffic
is now believed only from the task that holds the contract it speaks for.
A capability arriving on a request outlived every path that ignored it,
one handle per call until the table was full — in init, and in the harness
ten services share — so the arriving capability is owned by the turn and
released unless a handler says otherwise. And the kernel let anyone holding
an endpoint handle aim signals, timers, exit notices and interrupts at it:
binding now requires having created it.

Suite 108/108. The new protocol-registry case asserts eleven properties,
each one an attack that must fail.
2026-08-01 02:39:07 +01:00
Daniel Samson 1ff0991452 library: the envelope — one packet shape for every protocol
A protocol is now defined through envelope.Define rather than beside it.
Every packet begins with the same 16-byte prefix: a Header of {operation,
target} for a request or event, a Status for a reply. The header is folded
into each protocol's own layout, never stacked on top of it — Define walks
a spec's types and refuses one that carries a Header or Status field, so
the rule cannot be missed by reading. Operations number from 16, leaving
describe, enumerate, subscribe and unsubscribe reserved and answerable the
same way everywhere; describe the envelope answers itself, and an unknown
verb is -ENOSYS without a provider writing a line.

The size floor becomes something protocols can compile against: 256 bytes
for a call, 64 for a push, matching the kernel's own limits, checked at the
Define call with the prefix counted in. A protocol that outgrows either
fails the build with the reason and the remedy — shrink, or move the bulk
to shared memory, because packets never fragment. Events carry the header
too and number in their own space, so appending an operation can never
renumber a shipped event.

Beside it, the client half of the model: a Channel is what opening a
/protocol name yields, wrapping the endpoint capability so a program holds
a conversation rather than a handle. Nothing speaks through either yet —
the vfs protocol gains NodeKind.protocol and the convention that an open
reply may carry a capability, and that is the whole runtime change.

Suite 107/107, unchanged: P1 converts nothing.
2026-07-31 21:48:03 +01:00
95 changed files with 7587 additions and 1955 deletions
+9
View File
@@ -311,6 +311,11 @@ pub fn build(b: *std.Build) void {
const init_csv_source = if (diagnose) "system/configuration/init-diagnose.csv" else "system/configuration/init.csv";
bundled_list.append(b.allocator, .{ .path = "system/configuration/devices.csv", .binary = b.path("system/configuration/devices.csv") }) catch @panic("OOM");
bundled_list.append(b.allocator, .{ .path = "system/configuration/init.csv", .binary = b.path(init_csv_source) }) catch @panic("OOM");
// The protocol grants: who may claim which name under /protocol
// (docs/os-development/protocol-namespace.md). init reads it beside init.csv,
// out of the same read-only initrd, before it spawns anything — the registrar
// has to know its policy before the first provider asks.
bundled_list.append(b.allocator, .{ .path = "system/configuration/protocol.csv", .binary = b.path("system/configuration/protocol.csv") }) catch @panic("OOM");
// A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the
// production set only. The userspace test fixtures under /test join in only
// for a test build — which the QEMU harness signals by passing
@@ -321,6 +326,7 @@ pub fn build(b: *std.Build) void {
if (test_case != null) for ([_][]const u8{
"vfs-test", // the user-space VFS round-trip client
"fat-test",
"badge-scope-test", // the guessable-id probe: a second process names the first's node and layer
"shared-memory-server",
"shared-memory-client",
"crash-test", // hellos to the device manager, then faults — drives the crash-loop cap
@@ -333,6 +339,9 @@ pub fn build(b: *std.Build) void {
"process-test",
"thread-test", // the multi-threaded fixture (its package sets .threaded)
"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");
+4
View File
@@ -63,6 +63,7 @@
.@"virtio-gpu" = .{ .path = "system/drivers/virtio-gpu" },
.@"vfs-test" = .{ .path = "test/system/services/vfs-test", .lazy = true },
.@"fat-test" = .{ .path = "test/system/services/fat-test", .lazy = true },
.@"badge-scope-test" = .{ .path = "test/system/services/badge-scope-test", .lazy = true },
.@"shared-memory-server" = .{ .path = "test/system/services/shared-memory-server", .lazy = true },
.@"shared-memory-client" = .{ .path = "test/system/services/shared-memory-client", .lazy = true },
.@"crash-test" = .{ .path = "test/system/services/crash-test", .lazy = true },
@@ -75,6 +76,9 @@
.@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true },
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
.@"protocol-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
@@ -64,19 +64,48 @@ restarted instance to rebuild exactly the same ids.
## The protocol
A `device-manager-protocol` module (the vfs-protocol pattern): extern-struct
messages, a version in the handshake, reserved fields everywhere. The manager is a
well-known endpoint (`ipc.register(.device_manager)`); the badge tells it who is
A `device-manager-protocol` module, defined through the
[envelope](../os-development/protocol-namespace.md): every packet — request,
reply, and pushed event alike — begins with the folded `Header`, and **the device
id is `Header.target`**, the manager's object addressing. The contract is bound at
`/protocol/device-manager`; the kernel-stamped badge tells the manager who is
talking; the same endpoint receives its children's exit notifications — one loop,
one world.
| Direction | Message | Purpose |
| Direction | Packet | Purpose |
|---|---|---|
| driver → manager | `hello { version, role, device_id }` | confirms the argv assignment, starts the deadline clock |
| bus → manager | `child_added { parent, bus_address, identity, device_id, hid }` | one node the bus discovered |
| driver → manager | `hello { role, version }` @ the assigned device | confirms the argv assignment, starts the deadline clock |
| bus → manager | `child_added { parent, bus_address, identity, bus, vendor, device, subsystem, hid }` @ the registered device id | one node the bus discovered |
| bus → manager | `child_removed { parent, bus_address }` | unplug, or the bus lost it |
| app → manager | `enumerate` | snapshot of the tree (read-only) |
| app → manager | `subscribe` | receive published add/remove events |
| app → manager | `enumerate` (reserved verb 1) | snapshot of the tree: one `ChildEntry` per record in the reply's tail |
| app → manager | `subscribe` (reserved verb 2) | receive published add/remove events; the subscriber's endpoint rides as the call's capability |
| manager → app | `child_added` / `child_removed` events | the same two structs, pushed rather than called |
The watcher table behind those last two rows is the **service harness's**
(`service.Subscribers`, shared with input and power), not the manager's: it
answers `subscribe`/`unsubscribe`, frames each event once for the fan-out, and
sweeps a watcher on its exit notification — where the manager previously had no
sweep for watchers at all. Its own supervised-driver exits are a different thing
and unchanged, except that a driver's death now arrives twice (the manager is
both its supervisor and a subscriber to published exits), so the manager retires
a dead driver's process id as it handles the first and the second finds nothing
to act on.
Two of what used to be the manager's own operations are the envelope's **reserved**
verbs, which mean the same thing at every provider in the system, so this protocol
numbers only three of its own (`hello` = 16, `child_added` = 17,
`child_removed` = 18) and its two events in their own space (`child_added` = 16,
`child_removed` = 17). No reply carries a status field: that is the `Status` every
reply begins with.
`child_added` is the one struct that travels both ways — a bus *calls* it, the
manager *pushes* it — which is why the operation and event numbering spaces are
separate: one encoding, both directions, told apart by which way the packet went.
Folding the operation byte and the device id out of it is also what makes it fit:
a pushed event is 64 bytes at most, header included, and this one lands exactly on
that floor. `child_removed` is the single message whose target stays 0, because it
is addressed by the composite (parent, bus address) and no single `u64` carries a
pair.
`hello` is the one deadline the manager enforces itself: spawned and silent past the
deadline means wrong binary, wrong protocol version, or wedged before main — apply
+18 -4
View File
@@ -87,13 +87,13 @@ rest of the system hasn't had to face:
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format, refresh_hz})
▼
display service (system/services/display/, ServiceId.display) ← the compositor
display service (system/services/display/, /protocol/display) ← the compositor
│ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
│ mmap(cacheable) a BACK buffer of the same geometry
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid)
│ loop: composite dirty layers → back buffer → present dirty rects → front
│ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2)
▼ reached by name (ipc_lookup); clients drive it over the display protocol
▼ reached by name (open /protocol/display); clients drive it over the display protocol
┌────────────────────────────────────┬──────────────────────────────────────┐
drawing clients (v1) surface clients (deferred)
display commands: display surfaces:
@@ -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
@@ -209,6 +211,18 @@ shell, a terminal, a cursor, and a wallpaper:
| `damage` | mark a region of a layer dirty |
| `present` | request a repaint: composited at the next frame-clock tick |
**A layer belongs to the client that created it.** The id is a slot in a
sixteen-entry table — small, dense, guessable — so every verb above that names one is
answered only for the task whose `create_layer` produced it, and a layer that is
somebody else's is refused exactly as one that never existed (`-ENOENT`), so a client
cannot use the refusal to learn which ids are live
([protocol-namespace.md](../os-development/protocol-namespace.md): handles are scoped
per client, validated against the badge). The compositor's own layers — the cursor
sprite and the startup self-check's pair — are marked service-owned and are created by
direct call rather than over the protocol, so no client can move or destroy the
cursor. A dead client's layers are released on its exit notification, the same sweep
the FAT server runs for open files.
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
(the [PSF font](../../system/kernel/font.psf) path the console already uses can move into a
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
+35 -12
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
@@ -98,12 +108,25 @@ This is the async counterpart of `ipc_call`, and the input service is its first
`publishJoystickEvent`. Publishing is a short synchronous `ipc_call` the service answers at
once; the service's own fan-out is asynchronous, so publishing never blocks on a slow
subscriber.
- The **service** ([input.zig](../../system/services/input/input.zig)) keeps a small subscriber
table (endpoint handle + owning task id + `device_mask`). On `publish` it `ipc_send`s the
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.
- The **service** ([input.zig](../../system/services/input/input.zig)) owns none of that
machinery any more: the subscriber table (endpoint handle + owning task + interest mask),
the reserved `subscribe`/`unsubscribe` verbs, the fan-out, and the dead-subscriber sweep
are the shared harness's (`service.Subscribers` in
[service.zig](../../library/kernel/service.zig)), so every event stream in the system has
identical semantics. What is left in this file is what is actually about input: which
class an event belongs to, and which classes a subscriber asked for. On `publish` it names
the event's class and the harness `ipc_send`s the packet — framed once — to every
subscriber whose mask includes it.
- **A dead subscriber goes away on its exit notification**, not on a poll. The service used
to walk `process_enumerate` on every subscribe and drop slots whose owner had gone; it now
subscribes to the kernel's published exits like the FAT server and the compositor do
([process-lifecycle.md](../os-development/process-lifecycle.md)), which reclaims the slot
*and* closes the endpoint capability in it promptly rather than at the next subscribe.
(The fan-out also drops a subscriber whose `ipc_send` fails, as a backstop for a
notification a full ring dropped.)
- The service runs on the shared harness 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
+25 -3
View File
@@ -114,9 +114,10 @@ unforgeable source addressing, a property a network's source field lacks.
The bootstrap problem — how a channel is first established — is the subject of
[protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is
resolved by name and the channel arrives as a capability. (The mechanism this
replaces, `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers,
is retired by that design.)
resolved by name and the channel arrives as a capability. (The mechanism it
replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers —
is gone: both syscalls and the enum were deleted when the registry landed, and
their syscall numbers are left vacant.)
### Interrupts are signals
@@ -169,6 +170,27 @@ signal mechanism:
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
timer-bit signal — the timed wait: a service arms a deadline and keeps
serving, instead of blocking in sleep.
- **Kernel notifications go only to your own endpoint.** `signal_bind`,
`timer_bind`, `process_subscribe`, `irq_bind`, `msi_bind`, and spawn's exit
endpoint all *nominate where the kernel will speak*, and all of them refuse an
endpoint the caller did not create (`-EPERM`; the check is `ipc.ownedBy`,
normalized to the process, so any thread may nominate an endpoint a sibling
created). Holding a handle is not enough, because holding a handle is cheap:
`fs_resolve` installs a mounted backend's capability in *any* caller's table,
so every process holds a handle to PID 1's mailbox. Without the rule, "bind
init's endpoint, then signal yourself" is a genuine, kernel-stamped `terminate`
badge in PID 1's queue — a shutdown a receiver has no way to disbelieve — and
timers, which carry no identity at all, multiply any loop that re-arms on its
own landing.
- **A capability that arrives belongs to the turn.** The kernel installs a sent
capability in the receiver's table whatever the message's length or kind, so a
receive loop must dispose of one on *every* path — the ping, the notification,
the malformed request. The service harness (`service.run`) and PID 1 both hold
it in an `ipc.Arrival`, released by a `defer`, and a handler that means to keep
it says `take()`: forgetting closes, keeping is explicit. The reverse
arrangement leaks a handle-table slot per request, and thirty-two unauthorized
zero-length pings then end a service's ability to accept any capability —
no subscribe, no shared-memory handover — for the rest of the boot.
- **The universal ping**: a **zero-length request is the liveness probe**,
answered with a zero-length reply by the service harness itself
(`service.run`). No protocol's requests start at length zero, so the
+126 -72
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 (0–16 bytes), then
an inline tail of at most **224 bytes** (`maximum_payload`) — a path, or
write bytes. There is no multi-message request: paths and single
reads/writes must fit, and larger transfers loop (see *read* / *write*).
- A reply is the status, then the verb's own fixed part, then an inline tail
— read bytes, or a directory entry's name.
- All integers are **little-endian**; layouts are C layout for x86-64
(`extern struct`), offsets given below so nothing need be inferred.
@@ -37,91 +45,108 @@ With clients holding backend node ids directly, a backend records each open
handle's owner and sweeps a dead client's handles via the published process
exit events.
## Request header — 32 bytes
## Request header — 16 bytes
The envelope's `Header`, identical in every danos protocol:
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `operation` | an **Operation** value (below) |
| 0 | 4 | `operation` | an **Operation** value (below); 0–15 are the reserved universal verbs |
| 4 | 4 | — | padding |
| 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations |
| 16 | 8 | `offset` | byte position for read/write; entry index (cursor) for readdir; else 0 |
| 24 | 4 | `len` | payload length for path/write operations; requested byte count for read |
| 28 | 4 | `flags` | open flags (below); else 0 |
| 8 | 8 | `target` | **the open-node id** from a prior `open`; 0 for `open` itself and the path-based verbs |
## Reply header — 24 bytes
## Reply header — 16 bytes
The envelope's `Status`:
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `status` | **0 = success**, negative = failure (signed) |
| 4 | 4 | — | padding |
| 8 | 8 | `node` | the new open-node id (for `open`); else 0 |
| 16 | 4 | `len` | reply payload length in bytes |
| 20 | 4 | — | padding |
| 8 | 4 | `len` | reply bytes following this header: the verb's fixed part plus its tail |
| 12 | 4 | — | padding |
On failure the backend replies `status = -1`, and that reply reaches the
client directly — there is no party between them on the wire. (Kernel-served
paths produce no wire replies at all: `fs_resolve`/`fs_node` failures are
syscall register statuses.) A richer errno vocabulary is future work —
clients must treat *any* negative status as failure, not match on -1.
A failing backend replies with the status alone (`len` = 0) and no fixed
part, and that reply reaches the client directly — there is no party between
them on the wire. (Kernel-served paths produce no wire replies at all:
`fs_resolve`/`fs_node` failures are syscall register statuses.) The errno
vocabulary is the kernel's, continued by the envelope: `ENOENT` = 4 is what a
backend answers for anything it cannot find or cannot do, `ENOSYS` = 10 for a
verb it does not implement, `EPROTO` = 11 for a packet shorter than the verb
it names. Clients must treat *any* negative status as failure rather than
matching a particular one.
## Operations
Values are append-only and never renumbered (the same evolution rule every
danos protocol follows). Send only values from this table: the shipped server
decodes the operation into an exhaustive enum, so an out-of-range value is
not answered with a `status = -1` reply — it trips a safety check in safe
builds and is undefined otherwise. (The `-1` replies cover recognised but
refused operations, such as `mount` sent to a backend.)
Values number from 16 (`first_protocol_operation`) in declaration order, and
are frozen once shipped. Values 0–15 are the envelope's reserved universal
verbs, which mean the same thing at every provider in the system: `describe`
(0) answers the protocol's name and version and is implemented by the
envelope itself, so every backend answers it. A verb outside this table is
answered `-ENOSYS`; it is never a safety check any more, because the
dispatch compares numbers rather than decoding an enum.
| value | operation | request payload | reply |
|------:|-----------|-----------------|-------|
| 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id |
| 1 | `close` | — (`node` set) | status only |
| 2 | `read` | — (`node`, `offset`, `len` = wanted count) | `len` bytes read, payload = the bytes; `len` 0 at end of file |
| 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) |
| 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) |
| 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end |
| 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only |
| 7 | `unmount` | the mount-point path | status only |
| 8 | `mkdir` | the path | status only |
| 9 | `unlink` | the path | status only |
| 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only |
Each row's *request* and *reply* name the bytes **after** the 16-byte prefix.
| value | operation | request | tail | reply | reply tail |
|------:|-----------|---------|------|-------|-----------|
| 16 | `open` | `flags` (4 bytes, below) | the path | `node` (8 bytes) = the open-node id | — |
| 17 | `close` | — | — | — | — |
| 18 | `read` | `offset` (8), `len` (4) = wanted count | — | — | the bytes read; `Status.len` 0 at end of file |
| 19 | `write` | `offset` (8), `len` (4) = count | the bytes | `count` (4) = bytes accepted (may be short — loop) | — |
| 20 | `status` | — | — | **FileStatus** (24 bytes) | — |
| 21 | `readdir` | `cursor` (8) | — | one **DirectoryEntry** (16 bytes) | the name |
| 22 | `mount` | — | the mount-point path; the backend endpoint rides as the call's **capability** | — | — |
| 23 | `unmount` | — | the mount-point path | — | — |
| 24 | `mkdir` | — | the path | — | — |
| 25 | `unlink` | — | the path | — | — |
| 26 | `rename` | — | old path, one `0x00`, new path | — | — |
| 27 | `bind` | — | the contract name; the provider's endpoint rides as the call's **capability** | — | — |
Notes per operation:
- **open** — the path is the mount-relative path `fs_resolve` handed back
(absolute-shaped: `/notes.txt` under fat's `/mnt/usb` mount). Bare names
(absolute-shaped: `/notes.txt` under fat's `/volumes/usb` mount). Bare names
(`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve`
refuses non-absolute paths. The returned `node` is the *backend's* own
open-node id: with the router in the kernel there is no forwarding table,
and clients hold backend ids directly (see *Lifetimes and trust*).
and clients hold backend ids directly (see *Lifetimes and trust*). Every
later packet carries it in `Header.target` — the path is spoken once, here,
and integers do the rest.
- **read / write** — a single exchange moves at most 224 bytes
(`maximum_payload`); the client loops, advancing `offset` by the returned
`len`, until done (read) or the slice is written (write). A `write` reply
shorter than requested is progress, not an error; a `len` of 0 means no
forward progress — stop rather than spin.
- **readdir** — `offset` is a **cursor: the entry index**, not a byte
position. Each call returns exactly one entry; the client increments the
cursor by 1. A reply with `len` 0 is end-of-directory. The directory must
have been opened with the `directory` flag.
(`maximum_payload`); the client loops, advancing its own offset by what
came back, until done (read) or the slice is written (write). A `write`
reply shorter than requested is progress, not an error; a count of 0 means
no forward progress — stop rather than spin.
- **readdir** — `cursor` is the **entry index**, not a byte position. Each
call returns exactly one entry; the client increments the cursor by 1. **A
`name_len` of 0 is end-of-directory** — the reply's own length cannot say
so, because the envelope always sends the fixed reply part. The directory
must have been opened with the `directory` flag.
- **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount`
syscall now (a filesystem server passes its endpoint handle; possession is
the capability, exactly the trust of the old cap-passing op). The op
the capability, exactly the trust of the old cap-passing op). The verb
numbers stay reserved. Mount-prefix semantics are unchanged: prefixes
match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`),
the longest matching prefix wins, and an optional backend-side rewrite
prefix maps a mount into the backend's namespace (fat serves `/mnt/usb`
from its volume root and `/var` from its `/var` subtree).
match at path boundaries only (`/volumes/usb` never captures
`/volumes/usbextra`), the longest matching prefix wins, and an optional
backend-side rewrite prefix maps a mount into the backend's namespace (fat
serves `/volumes/usb` from its volume root and `/system/logs` from its
`/system/logs` subtree).
- **rename** — same-directory rename only: the backend compares the old and
new parent paths and refuses a mismatch. The client (`file_system`) refuses
earlier when the two paths resolve to different backend endpoints, but that
check is coarser than "one mount" — one endpoint can serve several mounts
(fat serves `/mnt/usb` and `/var`), so a cross-mount rename reaches the
backend and fails on its same-directory check.
(fat serves `/volumes/usb`, `/system/configuration` and `/system/logs`), so
a cross-mount rename reaches the backend and fails on its same-directory
check.
- **bind** — the protocol registry's claim verb, implemented only by the
synthetic `/protocol` backend inside PID 1
([protocol-namespace.md](../os-development/protocol-namespace.md)). A file
backend answers `-ENOSYS`.
## Open flags
Bitwise OR in `Request.flags`, meaningful for `open` only:
Bitwise OR in `open`'s `flags`, meaningful for `open` only:
| bit | name | meaning |
|----:|------|---------|
@@ -129,7 +154,7 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
| 2 | `directory` | open a directory node for `readdir` rather than a file |
| 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) |
## FileStatus — 24 bytes (the `status` reply payload)
## FileStatus — 24 bytes (the `status` reply's fixed part)
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
@@ -138,12 +163,12 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
| 12 | 4 | — | padding |
| 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none |
## DirectoryEntry — 16 bytes + name (the `readdir` reply payload)
## DirectoryEntry — 16 bytes + name (the `readdir` reply)
| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `kind` | a **NodeKind** value |
| 4 | 4 | `name_len` | length of the name that follows |
| 4 | 4 | `name_len` | length of the name that follows; **0 means end of directory** |
| 8 | 8 | `size` | the entry's size in bytes |
| 16 | `name_len` | name | the entry's name, not NUL-terminated |
@@ -161,25 +186,50 @@ Aligned to the node-kind table in the file-system hierarchy
| 4 | symbolic link |
| 5 | fifo |
| 6 | socket |
| 7 | protocol |
Clients should map unknown values to *regular* rather than reject — the
table can grow. Kind 6 (`socket`) keeps its wire value but is retired from
the design — a named rendezvous point is exactly what a `protocol` node is,
planned as value 7 with the protocol namespace
landed as value 7 with the protocol namespace
(docs/os-development/protocol-namespace.md). `character_device` (stream
semantics — the tty/console shape) and `block_device` (raw sector-addressed
volumes, reserved) remain part of the design.
## An open reply may carry a capability
`open` rides `ipc_call`, whose reply direction can hand back an endpoint
capability alongside the reply. A file backend never uses it — FAT
answers with a node id and nothing else — but a **synthetic** backend does:
opening a `protocol` node returns the provider's endpoint, and possession of
that endpoint *is* the channel. The convention is per-backend, not
per-operation, so a client that opens an ordinary file simply receives no
capability, exactly as before.
## Lifetimes and trust
Open-node ids live in the backend. A client that dies without closing leaks
nothing permanently: the backend (the FAT server) subscribes to the kernel's
published process-exit events (docs/process-lifecycle.md) and releases a dead
client's handles. The kernel VFS root needs no sweep at all — its node tokens
are permanent for a boot and carry no open state. Ids are plain integers, not
capabilities — a backend trusts its callers with each other's ids today, which
is acceptable while every client is part of the system image and worth
revisiting (per-client id namespaces) before third-party binaries arrive.
are permanent for a boot and carry no open state.
Ids are plain integers rather than capabilities, so the backend **scopes them
to the caller's badge**: an open node belongs to the task that opened it, and
every verb that names one — read, write, status, readdir, close — is answered
only for that task. A node id is a small number drawn from a table of
thirty-two, trivially guessable, and until this rule a backend honoured every
client's ids from every other client
(docs/os-development/protocol-namespace.md: *handles must be scoped per
client — validated against the badge, or drawn from a per-client id
namespace*).
The refusal is deliberately **identical to absence**: a node that is somebody
else's answers `-ENOENT`, exactly as one that was never opened, so a prober
learns nothing about which ids are live — the same discipline the protocol
namespace applies to a refused open. The owner is a *task*, because the badge
is: a threaded client uses a node from the thread that opened it, which is
already the granularity of the exit sweep that releases it.
## Evolution rules
@@ -187,11 +237,15 @@ What a non-Zig implementation may rely on, and what it must not:
- Operation values, flag bits, `NodeKind` values, and struct layouts are
**append-only and frozen once shipped**. The unit test in
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry`
size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the
full record of the frozen values.
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the
`DirectoryEntry` size, `NodeKind` 0–1 and 6–7, `Operation` values 16–21, 26
and 27); this page is the full record of the frozen values.
*The one renumbering this contract has had was the rebase onto the envelope
(P4a), which moved every verb above the reserved range — a deliberate
flag-day across a system with no third-party clients yet, not a precedent.*
- The 256-byte message ceiling is a property of the current IPC transport,
not a promise; clients should read `maximum_payload`-shaped limits from the
reply lengths they actually get (loop-until-done), not hard-code 224.
- Negative statuses beyond -1 will appear (an errno vocabulary); success is
exactly 0.
- Success is exactly 0, and the negative statuses come from one system-wide
errno vocabulary (the kernel's, continued by the envelope) rather than from
this protocol.
+2 -2
View File
@@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work:
- **Cross-firmware surfaces are named by domain, not firmware.** System power is
a [`power`](power.md) protocol, not an "ACPI events" protocol: on x86 the acpi
service registers it, on ARM a PSCI/mailbox service registers the same
`ServiceId.power`, and subscribers never learn the difference.
service binds it, on ARM a PSCI/mailbox service binds the same
`/protocol/power`, and subscribers never learn the difference.
- **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s
8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string
(`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can
+36 -18
View File
@@ -15,9 +15,9 @@ Where the events come from is firmware-specific — on x86 they ride the ACPI SC
([acpi.md](acpi.md)); on a Raspberry Pi they would come from PSCI or a mailbox.
What subscribers want is not: *the lid closed* means the same thing regardless of
who noticed. So the surface is **domain-named**. There is a `power-protocol`
module and a well-known `ServiceId.power = 5`; on x86 the **acpi service**
registers it, and on ARM a PSCI/mailbox service will register the *same* id.
Subscribers call `ipc.lookup(.power)` and never learn which firmware they
module and a contract named `/protocol/power`; on x86 the **acpi service**
binds it, and on ARM a PSCI/mailbox service will bind the *same* name.
Subscribers open `/protocol/power` and never learn which firmware they
are on — the neutrality the whole [discovery](discovery.md) migration exists to
preserve, carried one layer up into a running-system surface.
@@ -28,28 +28,43 @@ unchanged.
## The protocol
The `power-protocol` module ([library/protocol/power/power-protocol.zig](../../library/protocol/power/power-protocol.zig))
follows the vfs-protocol pattern — extern-struct messages, a version, reserved
fields. Three operations:
is defined through the [envelope](protocol-namespace.md), so every packet begins
with the folded `Header`. `Header.target` is unused in both directions: the
provider is the only object either side addresses.
| Direction | Operation | Purpose |
| Direction | Packet | Purpose |
|---|---|---|
| subscriber → service | `subscribe` | receive published events; the subscriber's endpoint rides as the call's **capability** (the input/device-manager pattern) |
| init → service | `shutdown` | orderly shutdown's last step: enter S5 (soft off) |
| service → subscriber | `event` | a published `EventMessage`, delivered as a buffered message (never sent *to* the service) |
| subscriber → service | `subscribe` (reserved verb 2) | receive published events; the subscriber's endpoint rides as the call's **capability** (the input/device-manager pattern) |
| init → service | `shutdown` (verb 16) | orderly shutdown's last step: enter S5 (soft off) |
| service → subscriber | one event per kind | a published `Notice`, `ipc_send`t as a buffered packet (never sent *to* the service) |
`subscribe` is not one of this protocol's own verbs: a synchronous call whose
attached capability is the subscriber's endpoint is exactly what the envelope's
reserved `subscribe` means everywhere, so power adopts it wholesale. And no
packet carries a version — the reserved `describe` verb is the version handshake,
asked once at connect time rather than out of every packet's budget.
Events are published, not polled: like the input service, the service holds
subscriber endpoints as capabilities and `ipc_send`s each event as a buffered
message, so a slow or dead subscriber can never wedge the source. The event
packet, so a slow or dead subscriber can never wedge the source. The table, the
reserved `subscribe`/`unsubscribe` verbs and the fan-out are the **service
harness's** (`service.Subscribers`), shared with input and the device manager, so
the acpi service's own code is the ACPI half only — and a subscriber that dies is
now swept on its exit notification, where before this service had no sweep at
all. **The kind is the packet's operation** — one declared event per named kind, exactly as the
input service delivers one per device class — so a subscriber reads *what
happened* out of the header rather than out of a tag inside the payload. The
vocabulary is hardware-neutral:
- `power_button` — the button was pressed (a fixed ACPI event on x86).
- `lid`, `ac`, `battery` — the named GPE-driven events.
- `notify` — a device notification that maps to none of the above; its `code`
(the ACPI `Notify` argument) and the notifying device's `hid` say which device
and what happened.
- `power_button` (event 16) — the button was pressed (a fixed ACPI event on x86).
- `lid` (17), `ac` (18), `battery` (19) — the named GPE-driven events.
- `notify` (20) — a device notification that maps to none of the above; its
`code` (the ACPI `Notify` argument) and the notifying device's `hid` say which
device and what happened.
An `EventMessage` carries the `event` tag plus `code` and an 8-byte `hid`, so a
generic `notify` is fully described without a second round trip.
The payload every one of them carries is a `Notice`: `code` plus an 8-byte `hid`,
so a generic `notify` is fully described without a second round trip, and the
four named kinds leave both fields zero because the verb already said it all.
**`shutdown` is authority, not information.** It is the only operation that
*does* something irreversible, so it is gated: the contract is that only init
@@ -58,7 +73,10 @@ sequence over everything else. The acpi service implements this as a **soft
gate** — it honors `shutdown` only from a process that is a *subscriber*, and
init is the one subscriber. That stands in for "only the system supervisor may
power off" without hard-coding a pid, so it still holds under tests where PID 1
is not init.
is not init. The question is asked of the harness's table now
(`Subscribers.has(sender)`), which is why the harness exposes it: the gate is
unchanged, including the badge being the whole of it — the badge is
kernel-stamped, so nothing inside a packet can claim to be init.
## Orderly shutdown
+13 -3
View File
@@ -188,9 +188,15 @@ zombie state or privileged snooping:
state by all along is the id the exit event carries.
Subscription, not broadcast-to-everyone: only processes that asked receive
events, the kernel keeps a bounded subscriber table, and delivery is the same
non-blocking coalescing notification as everything else — a dying process never
waits on its mourners. Subscribing is ungated, like `process_enumerate`: what is
events, the kernel keeps a bounded subscriber table (sixteen — a normal boot
already fields six, since this is what *every* provider with per-client state
releases on), and delivery is the same non-blocking coalescing notification as
everything else — a dying process never waits on its mourners.
A service does not usually write the sweep itself: the shared service harness
subscribes for it and drops a dead task's event subscriptions
(`service.Subscribers`), and a provider adds its own handler only for state the
harness knows nothing about — open files, layers, device tokens.
Subscribing is ungated, like `process_enumerate`: what is
running (and dying) is not a secret between cooperating processes. Subscribers
do not receive the exit reason — the filesystem server does not care *why*
the client died.
@@ -285,6 +291,10 @@ callbacks (`on_terminate`, `on_reload`) for programs that want defaults.
`service` owns the `replyWait` loop and folds every event source — signals,
child exits, protocol messages — into callbacks, with the vocabulary's defaults:
it also owns the **subscriber side** of any protocol that declares events
(`service.Subscribers`: the table, the reserved `subscribe`/`unsubscribe` verbs,
the fan-out, and the sweep on a subscriber's published exit), so every event
stream in the system behaves identically.
`terminate` returns from the loop (clean exit), the common `ping` is answered automatically,
`reload` is ignored unless overridden. One loop, no locking, nothing reentrant. A
service author writes domain logic; the lifecycle contract is satisfied by the
+4 -2
View File
@@ -1,7 +1,9 @@
# The protocol namespace
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. Not yet implemented —
the migration plan at the end is the work list.*
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the
migration plan at the end have landed (the envelope, the registry and the
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
work list.*
How a program finds, connects to, and is restricted from the things it talks to.
Three ideas, kept deliberately separate:
+1 -1
View File
@@ -27,7 +27,7 @@ packages whose build.zig calls `build_support.userBinary` (with `.threaded =
true` where a binary spawns threads) and get packed into the initial-ramdisk;
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
`library/kernel` wrapper; test services live beside the code they exercise and
register a `ServiceId` if they must be looked up.
bind a `/protocol/test/...` name if they must be reachable.
## How to verify along the way
+8 -6
View File
@@ -270,13 +270,15 @@ stays single-threaded and lean.
- *Handles do not cross threads.* The handle table lives on the `Task`
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful
only to the thread that created it — thread A's endpoint handle `3` is not thread B's.
A thread that needs to reach an endpoint another thread owns looks it up
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint.
This is how the display's mouse-listener thread reaches the compositor loop's endpoint
to poke it awake (docs/display.md).
A thread that needs to reach an endpoint another thread owns opens the name
(`channel.openEndpoint("display")`) to install its **own** handle to the same
underlying endpoint — an ordinary client open, with no special mechanism for the
fact that the provider happens to be this process. This is how the display's
mouse-listener thread reaches the compositor loop's endpoint to poke it awake
(docs/display.md).
- *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.*
`create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and
mutate the global service registry, endpoint refcounts, and handle tables. Those paths
`create_ipc_endpoint` allocates from the kernel heap and
mutates endpoint refcounts and handle tables. Those paths
were unlocked because a single-threaded process could not race itself; a multi-threaded
one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/
`send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel
+3 -3
View File
@@ -137,15 +137,15 @@ Grouped as `abi.zig` groups them:
| process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` |
| threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` |
| memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` |
| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` |
| ipc | `danos_endpoint_create`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` (naming is not a syscall: a provider binds its contract at the registry and a client resolves `/protocol/<name>` — see [protocol-namespace.md](protocol-namespace.md)) |
| devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` |
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` |
| filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) |
The constants that ride alongside the calls — mmap protection bits, DMA
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
ids, `page_size`, the IPC message maximum — move to the public header too:
flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC
message maximum — move to the public header too:
they are wire values a Rust program needs verbatim. What stays private in
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
numbers and the trap convention.
+159 -8
View File
@@ -31,18 +31,55 @@ the logging/USB-lifecycle track).
## Status
**Live state — updated on `main` after every phase, so this file read from a
plain `main` checkout always tells the truth about where the work is.**
| | |
|---|---|
| Working on | **H2** — SMEP (group 4) |
| Branch carrying it | `feat/security-group-4` (cut next) |
| On `main` | everything through P4c — groups 1, 2 and 3 merged |
| Awaiting merge | nothing |
| Suite | 111 cases, all passing |
| Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was
committed — on the branch named above, which reaches `main` at the next
group boundary.
- [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed
- [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106)
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
- [ ] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel`
- [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups)
- [ ] **P3** — open grants: `protocol.csv` enforcement, denial test
- [ ] **merge** group 2 → main, push
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input)
- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer)
- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers
- [ ] **merge** group 3 → main, push
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
- [x] **P3** — open grants: `protocol.csv` enforcement, denial test. `onOpen`
consults the manifest with the same chain-attested identity a bind uses, and a
refused caller gets the *same* answer as one naming a contract nobody bound —
`-ENOENT`, no capability, the same reply bytes, no log line, and both questions
asked on every open so there is nothing to time. Twenty-seven `open` rows cover
the whole live client set. One wrinkle the plan had not foreseen: the driver
tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation
is one hop, so a legitimate grandchild read exactly like a laundering deputy;
the manifest gained a third permission, `supervise`, which names an authorized
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
- [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)
- [x] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer; every leading operation byte folded into the header, and with it the `device_id`/`device_token` that followed it — `Header.target` now carries the device in all three. device-manager's own `enumerate`/`subscribe` became the reserved verbs and its three `{status, reserved}` reply structs the envelope's `Status`; `ChildAdded` is one struct under two numbers, a call and an event, landing exactly on the 64-byte push floor. power's kinds became one declared event each, the input protocol's shape, so init reads *what happened* from the header; usb-transfer's control data stage moved to the packet tail in both directions, which made `Status.len` the transferred length and `actual_length` redundant. The two silent-breakage sites — init's byte-offset power parse and acpi's `message[0]` dispatch — are gone, the shutdown badge gate unchanged; three more rows in the conformance table. Suite 110/110)
- [x] **P4c** — harness subscriber lift + badge-scoped per-client integers (the
subscriber table, the reserved subscribe/unsubscribe verbs, the fan-out and the
dead-subscriber sweep are `service.Subscribers` now; input, acpi and
device-manager deleted three hand-rolled variants and their three different
ideas of when a subscriber goes away, standardizing on published exit
notifications — acpi had no sweep at all and input polled the process list on
every subscribe. The three guessable-id namespaces are scoped to the opening
badge: FAT node ids on every verb that names one, xHCI device tokens on open,
control, bulk and interrupt_subscribe, display layers on configure, fill, blit,
damage and destroy — each refusing a wrong owner with the *same* answer as an id
nobody holds. New `badge-scope` case, two processes of one fixture, every
refusal paired with a control; suite 111/111)
- [x] **merge** group 3 → main, push
- [ ] **H2** — SMEP on every core
- [ ] **HS** — SYSRET canonical-RIP guard
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
@@ -87,6 +124,35 @@ that implements it.
`init` for init's own children, `kernel` for harness-spawned fixtures),
not in extra init.csv columns — today every post-path init.csv field is
argv, and overloading that is ambiguous. init parses both files.
*(P2 spelling: the supervisor column carries the binary exactly as the
kernel stamped it, so init's own children say `/system/services/init` and
the drivers say `/system/services/device-manager`; `kernel` stays a bare
word because a kernel task has no binary. A trailing `*` on any field
matches a subtree, which is how decision 4's `/test/` rule is expressed.)*
*(Clarification, 2026-08-01: the supervisor column names **the authorized
supervising task, matched by identity** — the binary is how the row spells
it, but init checks the task id. `kernel` is satisfied only by supervisor
id 0 (which only the kernel confers — user `system_spawn` always stamps the
caller); init's own path only by this init's task id; any other path only by
a task init spawned itself or one the kernel spawned. Matching the supervisor
by *name* alone is defeated by a laundering deputy — an attacker runs its own
instance of `/system/services/init`, has that spawn `/system/services/input`,
and both stamped names satisfy the row while the chain is entirely the
attacker's. Walking to the root of the chain does not fix it either, since
the laundered chain still roots at the real PID 1.)*
*(P3 amendment: a third permission, `supervise`, joins `bind|open`. One-hop
attestation cannot express the one three-deep chain in the tree — the device
manager starts the PS/2 bus, and the bus starts the keyboard and mouse
drivers — and nothing structural tells that chain apart from the laundering
deputy, since both are a granted binary spawned by a granted binary. Only
policy can: a `supervise` row names the authorized supervising task the way
every other row names a claimant (binary, its own supervisor, the contract it
concerns), and an `open` row may then name that task in its supervisor
column. The delegate is itself attested the ordinary strict way, so the chain
still anchors in init or the kernel one hop above it and the recursion stops
there. It is **open-only** on purpose — a delegate may vouch for what its
children *reach*, never for what they *claim* — so the bind path is
byte-for-byte P2's and the laundering-deputy refusal is untouched.)*
4. **Test fixtures bind under `/protocol/test/...`**, granted to any
binary whose path starts `/test/` — the subtree-scoping rule from the
design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId
@@ -115,6 +181,10 @@ that implements it.
process-test) either spawn init first or move to init.csv-driven
scenario boots — resolved per-case in P2 with the suite as the
arbiter.
*(P2 resolution: init gained a `registry` argv role — it mounts
`/protocol`, reads the grants, and starts no services — and each affected
case calls `spawnRegistry(rd)` before its own providers. Every case keeps
its own spawn set, so no scenario had to be re-shaped.)*
10. **The capsule-staleness caveat is documented, not fixed.** On-volume
edits to `/system/configuration/*.csv` do not reach the initrd copy
the loader boots (capsule shadows tree). Same drift exists today with
@@ -277,6 +347,34 @@ Every existing scenario doubles as conversion proof. Suite 108.
the first succeeds and the second fails identically to not-found. Suite
109.
*Landed. Four things the plan did not foresee, recorded because P4 and P5
inherit them:*
- *`supervise` — decision 3's amendment. The PS/2 keyboard and mouse drivers
are started by the PS/2 bus driver, which the device manager started: the
tree's one three-deep chain, and one hop deeper than attestation reaches.
Nothing structural separates it from the laundering deputy, so the manifest
says which delegate is authorized, per contract. Open-only, so P2's bind
attestation is unchanged.*
- *Indistinguishability is a claim about work, not only about bytes. `onOpen`
refreshes the process table, identifies the caller, scans the grants and
scans the bindings on **every** open and forms one verdict at the end; and
it logs nothing on any branch, because `klog_read` is ungated (a line
written on one branch is a line the refused caller can read) and a serial
line is milliseconds it could time. The operator's diagnosis is the pair the
namespace publishes anyway: `readdir /protocol` for what is bound, the
manifest for who may reach it.*
- *The fixture is `protocol-denied-test`, and its scenario boots the **input
service** so the forbidden name is genuinely bound — the fixture reads the
namespace listing to prove it before asking for it. Without a live provider
the case would be comparing two boot races and asserting nothing.*
- *Two channels stay open by design, named rather than papered over: `readdir`
over `/protocol` lists every bound name to anyone (deliberate — the tree is
diagnosable), and `/system/configuration/protocol.csv` is world-readable on
the `/system` mount. Stage one hides neither the set of contracts nor the
policy; what it removes is the **oracle in the reply**, which is what stage
two's parked and faked opens depend on.*
## P4a — clean protocols onto Define
vfs, block, display, scanout, input — the modules whose shapes map
@@ -326,6 +424,32 @@ re-layouts and raw-offset readers):
USB storage/HID all exercise these wires); the conformance case now covers
three more providers. Suite 110.
*Landed. Three judgment calls the plan left open, recorded because a reader of
the wire formats will want them:*
- *`ChildAdded` is 48 bytes, not the 44 the "60 B" estimate assumed: three `u64`s
give the struct eight-byte alignment, so 41 bytes of content round up whatever
order the fields sit in. The packet is therefore **exactly** 64 — on the push
floor, not under it — which `Define` accepts and the module pins in a test. The
fields are ordered small-tail-last deliberately, so the slack the rounding pays
for is where the small ones live.*
- *power's events are declared **per kind** (`power_button`, `lid`, `ac`,
`battery`, `notify`), not one `event` with the kind in the payload. That is the
shape P4a gave input — "the class is the header's operation, so a subscriber
reads the kind from the packet rather than from a tag inside the payload" — and
it is what makes `Header.operation` carry information here at all. It also kept
every call site's spelling: `Protocol.Event` is re-exported as the protocol's
own `Event`, with the members it always had.*
- *the conformance case still checks two providers, and the three new rows report
as unbound. All three P4b contracts arrive with the device manager — it is the
first, it spawns the discovery service that binds the second, and the xHCI
driver that binds the third — so booting one means booting the driver tree, and
the fixture takes **one snapshot** of `/protocol`: a scenario whose bound set
depends on how far that tree got would make the case's own summary line a boot
race. The rows still earn their place — a future scenario that binds one gets it
checked with no edit here, and `/test/*` already holds the `open` grant for
`device-manager`.*
## P4c — harness subscriber lift + badge scoping
- `library/kernel/service.zig` grows the subscriber table, exit-
@@ -343,6 +467,33 @@ three more providers. Suite 110.
node id and asserts refusal; kernel-side unit test for the harness sweep.
Suite 111.
*Landed. Four things the plan had not foreseen:*
- *One sweep idiom means one more kernel subscriber per provider, and the kernel's
published-exit table held **eight**. A normal boot now fields six (fat, input,
power, device-manager, display, and one per xHCI controller), so the table grew
to sixteen. It is not a table anyone notices until a service silently loses its
sweep, which is exactly the failure the old ceiling was two subscriptions away
from.*
- *The device manager hears each of its drivers die **twice** now — it is both the
supervisor its spawn named and, through the harness, a subscriber to published
exits — and the notify ring delivers the two badges separately. Untreated, one
death counted as two: the restart backoff doubled and the crash-loop cap fired
at half the deaths it names. `onDriverExit` therefore retires the dead process
id before it decides anything, and the second notification finds nothing to act
on. (The `driver-restart` and `pci-scan` drills are what would have caught it.)*
- *Refusal-equals-absence has a corollary for the verbs that **release**: FAT's
`close` used to answer 0 for an unknown node, so scoping it had to change that
too — a foreign node and a free one both answer `-ENOENT`, or the pair would
have been an oracle for which ids are live. The same applies to the harness's
`unsubscribe`.*
- *Ownership is per **task**, not per process, because the badge is: the kernel
stamps the sending thread's id, which is already the granularity of the exit
sweep that releases the state (a worker thread's death releases the handles that
worker opened). Nothing in the tree shares an id across its own threads today;
a per-process notion would need the kernel to stamp the leader, and belongs with
P5's spawner-wired namespaces if it is ever wanted.*
## H2 — SMEP
- Generalize the cpuid helper (`apic.zig:351-365`, private, subleaf-0) to
+12
View File
@@ -12,10 +12,20 @@ pub fn build(b: *std.Build) void {
const ipc = kernel.module("ipc");
const time = kernel.module("time");
// Every client reaches its service by name now: resolve `/protocol/<name>`,
// open it, and take the provider's endpoint out of the reply
// (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") },
@@ -24,6 +34,8 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("input-client", .{
.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") },
+57 -90
View File
@@ -1,13 +1,17 @@
//! User-space display client: talk to the display service (query the mode, and — from D3
//! — create layers, draw, and present) without hand-rolling the IPC. The `runtime.block`
//! shape: a cached `.display` lookup with a boot-race retry, then extern-struct request/
//! shape: a cached `/protocol/display` open with a boot-race retry, then extern-struct request/
//! reply marshalling. See system/services/display/ and docs/display.md.
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,
@@ -19,13 +23,13 @@ pub const Info = struct {
/// The service endpoint, looked up once and cached.
var handle: ?ipc.Handle = null;
/// Look up the display service, retrying while it comes up (a client races its
/// registration at boot). Returns the endpoint, or null if it never appears.
/// Open `/protocol/display`, retrying while it comes up (a client races the
/// service's bind at boot). Returns the endpoint, or null if it never appears.
fn service() ?ipc.Handle {
if (handle) |h| return h;
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.display)) |h| {
if (channel.openEndpoint("display")) |h| {
handle = h;
return h;
}
@@ -34,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.
@@ -65,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;
}
@@ -106,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 };
}
+36 -20
View File
@@ -21,12 +21,14 @@
//! if (event.asKeyboard()) |k| { ... } else if (event.asMouse()) |m| { ... }
//! }
const std = @import("std");
const abi = @import("abi");
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;
@@ -44,13 +46,13 @@ pub const device_mouse = input_protocol.device_mouse;
pub const device_joystick = input_protocol.device_joystick;
pub const device_all = input_protocol.device_all;
/// Look up the input service, retrying while it is still coming up. Both a subscriber and
/// a source race the service's registration at boot, so both wait for it here rather than
/// failing. Returns the service endpoint handle, or null if it never appears.
/// Open `/protocol/input`, retrying while it is still coming up. Both a subscriber and
/// a source race the service's bind at boot, so both wait for it here rather than
/// failing. Returns the provider's endpoint handle, or null if it never appears.
fn lookupService() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.input)) |handle| return handle;
if (channel.openEndpoint("input")) |handle| return handle;
time.sleepMillis(50);
}
return null;
@@ -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.
+34 -25
View File
@@ -7,11 +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 {
@@ -19,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 };
}
@@ -33,47 +33,56 @@ 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];
}
};
/// One lookup attempt, no waiting — for a server that retries on its own
/// One open attempt, no waiting — for a server that retries on its own
/// timer (the fat service) instead of blocking its harness in here.
pub fn tryOpen() ?Device {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
return null;
}
/// Look up the block device, retrying generously while the USB storage chain
/// Open `/protocol/block`, retrying generously while the USB storage chain
/// (controller reset, enumeration, mass-storage bring-up) comes up.
pub fn open() ?Device {
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and
@@ -84,7 +93,7 @@ pub fn open() ?Device {
// completed at ~24 s); a machine whose stick genuinely failed setup should
// not sit a further minute pretending otherwise.
while (attempts < 600) : (attempts += 1) {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
time.sleepMillis(50);
}
return null;
+10
View File
@@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void {
const system_call = kernel.module("system-call");
const ipc = kernel.module("ipc");
const time = kernel.module("time");
// A driver finds the bus it attaches to by name — `/protocol/device-manager`,
// `/protocol/usb-transfer`, `/protocol/block`
// (docs/os-development/protocol-namespace.md).
const channel = kernel.module("channel");
// The devices sub-project's public interface (the flat wire types),
// importable by user space, unlike the kernel-internal device model it
@@ -55,7 +59,9 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("driver/driver.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "channel", .module = channel },
.{ .name = "device-abi", .module = device_abi },
.{ .name = "envelope", .module = protocol.module("envelope") },
.{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
@@ -81,6 +87,8 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("usb", .{
.root_source_file = b.path("usb/usb.zig"),
.imports = &.{
.{ .name = "channel", .module = channel },
.{ .name = "envelope", .module = protocol.module("envelope") },
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") },
@@ -92,6 +100,8 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("block", .{
.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") },
+22 -7
View File
@@ -7,6 +7,8 @@ const std = @import("std");
const abi = @import("abi");
const device_abi = @import("device-abi");
const sc = @import("system-call");
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const time = @import("time");
const device_manager_protocol = @import("device-manager-protocol");
@@ -167,25 +169,38 @@ const lookup_pause_ms: u64 = 20;
/// (best-effort standalone bring-up) or it refused the handshake. Bus drivers keep the handle
/// to report children through; a driver that runs fine unsupervised discards it with `_ =`,
/// and one that requires supervision bails on null. Logs the outcome itself.
///
/// The device this driver was assigned is the packet's `Header.target` — the manager's
/// object addressing, so `no_device` here is a driver that serves none.
pub fn hello(role: Role, device_id: u64) ?ipc.Handle {
var attempts: u32 = 0;
const manager = while (attempts < lookup_attempts) : (attempts += 1) {
if (ipc.lookup(.device_manager)) |handle| break handle;
if (channel.openEndpoint("device-manager")) |handle| break handle;
time.sleepMillis(lookup_pause_ms);
} else {
std.log.info("no device manager to hello", .{});
return null;
};
const message = device_manager_protocol.Hello{ .role = @intFromEnum(role), .device_id = device_id };
var reply: [device_manager_protocol.reply_size]u8 = undefined;
const length = ipc.call(manager, std.mem.asBytes(&message), &reply) catch {
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(
.hello,
device_id,
.{ .role = @intFromEnum(role) },
&.{},
&packet,
) orelse return null;
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
const length = ipc.call(manager, framed, &reply) catch {
std.log.info("hello call failed", .{});
return null;
};
if (length < device_manager_protocol.reply_size or
std.mem.bytesToValue(device_manager_protocol.HelloReply, reply[0..device_manager_protocol.reply_size]).status != 0)
{
const status = envelope.statusOf(reply[0..length]) orelse {
std.log.info("hello answered nothing readable", .{});
return null;
};
if (status.status != 0) {
std.log.info("hello refused", .{});
return null;
}
+79 -45
View File
@@ -11,15 +11,24 @@
//! _ = device.subscribeInterrupt(address, length); // reports arrive asynchronously
//! while (true) { ... ipc.replyWait(device.endpoint, ...) ... } // its own loop
//!
//! Reports are delivered to `device.endpoint` as asynchronous `InterruptReport`
//! messages (the class driver runs a bare `replyWait` loop to read them, because
//! the service harness drops buffered-message payloads — see service.zig).
//! Reports are delivered to `device.endpoint` as asynchronous `interrupt_report`
//! event packets, decoded with `reportOf` (the class driver runs a bare `replyWait`
//! loop to read them, because the service harness drops buffered-message payloads
//! — see service.zig).
//!
//! Every packet this file lays down is an envelope packet: the verb and the
//! device token in the folded `Header`, the transfer's own fields after it, and
//! a control transfer's data stage in the tail.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const time = @import("time");
const usb_transfer_protocol = @import("usb-transfer-protocol");
const Protocol = usb_transfer_protocol.Protocol;
/// The USB chapter-9 wire ABI and the class taxonomy, re-exported so a class driver reaches
/// the whole USB domain through its one `usb` import (`usb.abi.getDescriptor`, `usb.ids.Class`).
pub const abi = @import("usb-abi");
@@ -56,21 +65,41 @@ pub const Device = struct {
return null;
}
/// One request at the bus driver, addressing this device by its token — the
/// packet's `Header.target`, so no request body ever names the device again.
/// Null covers both a failed transport and a refusal: a class driver has the
/// same recourse either way.
fn call(
self: *Device,
comptime operation: Protocol.Operation,
request: Protocol.RequestOf(operation),
tail: []const u8,
capability: ?ipc.Handle,
reply: []u8,
) ?[]u8 {
var packet: [usb_transfer_protocol.message_maximum]u8 = undefined;
const framed = Protocol.encodeRequest(operation, self.token, request, tail, &packet) orelse return null;
const answer = ipc.callCap(self.bus, 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];
}
/// The data stage rides the tail in both directions, so the answer's length
/// *is* the transferred length — `Status.len`, which the envelope stamps.
fn controlTransfer(self: *Device, setup: [8]u8, direction_in: bool, data: []u8) ?usize {
var request = usb_transfer_protocol.ControlRequest{
.device_token = self.token,
if (data.len > usb_transfer_protocol.max_inline_data) return null;
const outgoing: []const u8 = if (direction_in) &.{} else data;
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
const answered = self.call(.control, .{
.setup = setup,
.direction_in = @intFromBool(direction_in),
.data_length = @intCast(data.len),
};
if (!direction_in and data.len > 0) @memcpy(request.data[0..data.len], data);
var reply: [@sizeOf(usb_transfer_protocol.ControlReply)]u8 = undefined;
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
if (length < @sizeOf(usb_transfer_protocol.ControlReply)) return null;
const control_reply = std.mem.bytesToValue(usb_transfer_protocol.ControlReply, reply[0..@sizeOf(usb_transfer_protocol.ControlReply)]);
if (control_reply.status != 0) return null;
const actual = @min(control_reply.actual_length, data.len);
if (direction_in and actual > 0) @memcpy(data[0..actual], control_reply.data[0..actual]);
}, outgoing, null, &reply) orelse return null;
const returned = Protocol.replyTail(.control, answered);
const actual = @min(returned.len, data.len);
if (direction_in and actual > 0) @memcpy(data[0..actual], returned[0..actual]);
return actual;
}
@@ -88,15 +117,11 @@ pub const Device = struct {
/// Begin periodic IN polling of an interrupt endpoint; reports flow back to
/// `self.endpoint` as asynchronous `InterruptReport` messages.
pub fn subscribeInterrupt(self: *Device, endpoint_address: u8, max_length: u16) bool {
var request = usb_transfer_protocol.InterruptSubscribeRequest{
.device_token = self.token,
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
return self.call(.interrupt_subscribe, .{
.endpoint_address = endpoint_address,
.max_length = max_length,
};
var reply: [@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]u8 = undefined;
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return false;
if (length < @sizeOf(usb_transfer_protocol.InterruptSubscribeReply)) return false;
return std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeReply, reply[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]).status == 0;
}, &.{}, null, &reply) != null;
}
/// Hand the controller a DMA-region capability (`handle` — from a `shareable`
@@ -105,48 +130,57 @@ pub const Device = struct {
/// will name in a `bulk` transfer, before the transfer. Harmless (and a no-op
/// success) when no IOMMU is enforcing. Returns false on failure.
pub fn attachDma(self: *Device, handle: ipc.Handle) bool {
var request = usb_transfer_protocol.DmaAttachRequest{ .device_token = self.token };
var reply: [@sizeOf(usb_transfer_protocol.DmaAttachReply)]u8 = undefined;
const result = ipc.callCap(self.bus, std.mem.asBytes(&request), &reply, handle) catch return false;
if (result.len < @sizeOf(usb_transfer_protocol.DmaAttachReply)) return false;
return std.mem.bytesToValue(usb_transfer_protocol.DmaAttachReply, reply[0..@sizeOf(usb_transfer_protocol.DmaAttachReply)]).status == 0;
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
return self.call(.dma_attach, {}, &.{}, handle, &reply) != null;
}
/// One bulk transfer (IN or OUT per `endpoint_address`'s direction bit) to or
/// from the caller's own DMA buffer at `physical`. Returns the bytes moved.
pub fn bulk(self: *Device, endpoint_address: u8, physical: u64, length: u32) ?u32 {
var request = usb_transfer_protocol.BulkRequest{
.device_token = self.token,
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
const answered = self.call(.bulk, .{
.physical_address = physical,
.length = length,
.endpoint_address = endpoint_address,
};
var reply: [@sizeOf(usb_transfer_protocol.BulkReply)]u8 = undefined;
const replied = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
if (replied < @sizeOf(usb_transfer_protocol.BulkReply)) return null;
const bulk_reply = std.mem.bytesToValue(usb_transfer_protocol.BulkReply, reply[0..@sizeOf(usb_transfer_protocol.BulkReply)]);
if (bulk_reply.status != 0) return null;
return bulk_reply.actual_length;
}, &.{}, null, &reply) orelse return null;
return (Protocol.decodeReply(.bulk, answered) orelse return null).actual_length;
}
};
/// Look up the USB bus and open the device with the assigned id, handing over a
/// freshly created endpoint for asynchronous interrupt reports. Retries while the
/// bus is still coming up (a class driver races the bus driver at boot).
/// Decode one asynchronous interrupt report out of a packet that arrived on the
/// class driver's own endpoint. Null when it is not one — a stray message, or a
/// packet too short to carry the report it names. The device it came from is the
/// packet's `Header.target`, which a single-device class driver never has to read.
pub fn reportOf(packet: []const u8) ?InterruptReport {
const event = Protocol.eventOf(packet) orelse return null;
if (event != .interrupt_report) return null;
return Protocol.decodeEvent(.interrupt_report, packet);
}
/// Open `/protocol/usb-transfer` and, on that channel, open the device with the
/// assigned id, handing over a freshly created endpoint for asynchronous interrupt
/// reports. Retries while the bus is still coming up (a class driver races the bus
/// driver at boot). Two opens, deliberately: the first names the contract, the
/// second names an object within it.
pub fn open(device_id: u64) ?Device {
var attempts: usize = 0;
const bus = while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.usb_bus)) |handle| break handle;
if (channel.openEndpoint("usb-transfer")) |handle| break handle;
time.sleepMillis(20);
} else return null;
const endpoint = ipc.createIpcEndpoint() orelse return null;
var request = usb_transfer_protocol.OpenRequest{ .device_id = device_id };
var reply: [@sizeOf(usb_transfer_protocol.OpenReply)]u8 = undefined;
const result = ipc.callCap(bus, std.mem.asBytes(&request), &reply, endpoint) catch return null;
if (result.len < @sizeOf(usb_transfer_protocol.OpenReply)) return null;
const open_reply = std.mem.bytesToValue(usb_transfer_protocol.OpenReply, reply[0..@sizeOf(usb_transfer_protocol.OpenReply)]);
if (open_reply.status != 0) return null;
// The assigned device id is the target: it is what the caller has before a
// token exists, and the token the reply hands back addresses every packet
// after this one.
var packet: [usb_transfer_protocol.message_maximum]u8 = undefined;
const framed = Protocol.encodeRequest(.open, device_id, {}, &.{}, &packet) orelse return null;
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
const result = ipc.callCap(bus, framed, &reply, endpoint) catch return null;
const answered = reply[0..result.len];
const status = envelope.statusOf(answered) orelse return null;
if (status.status != 0) return null;
const open_reply = Protocol.decodeReply(.open, answered) orelse return null;
var device = Device{
.bus = bus,
+41 -2
View File
@@ -52,13 +52,28 @@ pub fn build(b: *std.Build) void {
.{ .name = "time", .module = time },
},
});
_ = b.addModule("file-system", .{
const file_system = b.addModule("file-system", .{
.root_source_file = b.path("file-system.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .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):
// it needs the namespace (file-system, to resolve a /protocol name) and the
// transport (ipc) both, which is why it lives here rather than in a protocol
// module — those import nothing.
const channel = b.addModule("channel", .{
.root_source_file = b.path("channel.zig"),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "file-system", .module = file_system },
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
.{ .name = "envelope", .module = protocol.module("envelope") },
},
});
_ = b.addModule("memory", .{
@@ -70,10 +85,16 @@ pub fn build(b: *std.Build) void {
.{ .name = "thread", .module = thread },
},
});
// The harness binds the service's contract name at startup, which is a
// conversation with the registry — hence channel (and time, for the patience
// a provider that beat init to the mount needs). It also owns the subscriber
// table and the fan-out, which are expressed in the envelope's vocabulary
// (the reserved subscribe verb, the push floor) — hence envelope.
_ = b.addModule("service", .{
.root_source_file = b.path("service.zig"),
.imports = &.{
.{ .name = "abi", .module = abi },
.{ .name = "channel", .module = channel },
.{ .name = "envelope", .module = protocol.module("envelope") },
.{ .name = "ipc", .module = ipc },
.{ .name = "process", .module = process },
},
@@ -101,4 +122,22 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(kernel_tests).step);
}
// channel needs its whole import set to compile at all; only its framing is
// host-runnable (the syscall seams are x86_64-only, and unreferenced from
// the tests), so that is what it tests.
const channel_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("channel.zig"),
.target = b.resolveTargetQuery(.{}),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "file-system", .module = file_system },
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
.{ .name = "envelope", .module = protocol.module("envelope") },
},
}),
});
test_step.dependOn(&b.addRunArtifact(channel_tests).step);
}
+339
View File
@@ -0,0 +1,339 @@
//! `Channel` — layer L1 of the communication stack
//! (docs/os-development/communication.md) made concrete. A program holds a
//! channel that speaks a protocol; it does not hold a raw handle and marshal
//! bytes at one. The channel is the answer to "who am I talking to", decided
//! once at establishment, so nothing after that ever routes a party again:
//! every packet's `target` addresses an *object* within the peer already chosen.
//!
//! **Possession of the Channel is the connection.** There is no connect step, no
//! session id, no reconnect handshake — the endpoint capability inside is the
//! whole of the relationship, and it cannot be forged, only handed over. Which
//! also means a channel is a resource: `close` it, or it occupies a handle-table
//! slot for the life of the process.
//!
//! **A dead provider surfaces as `-EPEER`, and the recovery is to re-open.**
//! When the process on the other end exits, the kernel fails calls on its
//! endpoint rather than blocking forever; `call` returns null. The client does
//! not repair the channel — it discards it and opens the name again, which
//! reaches whatever instance the registry now points at. The restart story
//! falls out of the naming layer for free; no protocol needs a reconnect verb.
//!
//! `open` resolves a `/protocol/<name>` path through the kernel VFS router and
//! takes the provider's endpoint from the open reply's capability. The registry
//! answering it is init, PID 1, which mounts `/protocol` before it spawns anyone
//! (docs/os-development/protocol-namespace.md); `bind` below is the other half —
//! how a provider claims the name in the first place.
const std = @import("std");
const ipc = @import("ipc");
const time = @import("time");
const file_system = @import("file-system");
const vfs_protocol = @import("vfs-protocol");
const envelope = @import("envelope");
/// Longest `/protocol/...` path this client marshals. The registry's names are
/// short by construction (a contract leaf, not a file path), and the buffer is
/// on the stack of whoever opens.
pub const path_maximum: usize = 224;
/// Where the protocol namespace is rooted — the one path prefix in the system
/// that names contracts rather than files. Spelled once, here, so no caller
/// builds it by hand (docs/file-system-development/file-system-hierarchy.md).
pub const root: []const u8 = "/protocol";
/// Longest contract name — the part after `/protocol/`. Short by construction:
/// a leaf like `display`, or a subtree leaf like `test/shared-memory`.
pub const name_maximum: usize = 64;
/// What a `call` came back with: the provider's status, the reply payload (the
/// bytes after the `Status`, in the caller's own buffer), and any capability the
/// reply carried.
pub const Response = struct {
status: envelope.Status,
payload: []u8,
capability: ?ipc.Handle,
/// Whether the provider answered success. A negative status is its refusal
/// (`-ENOSYS` for a verb it does not implement, and so on).
pub fn succeeded(self: Response) bool {
return self.status.status == 0;
}
};
/// An open conversation with one provider, speaking one protocol.
pub const Channel = struct {
/// The provider's endpoint. Sending into it is the only thing this handle
/// can do — an endpoint is a mailbox owned by its creator, and that
/// direction never reverses.
endpoint: ipc.Handle,
/// Adopt an endpoint that arrived some other way — a capability delivered
/// in a reply, or one a supervisor wired in at spawn time (P5). The channel
/// takes ownership of the handle.
pub fn adopt(endpoint: ipc.Handle) Channel {
return .{ .endpoint = endpoint };
}
/// Establish a channel by name: resolve `/protocol/<name>` to the registry
/// backend, `open` the contract there, and take the provider's endpoint from
/// the reply's capability. Null if the path does not resolve, the registry
/// refuses (an ungranted name is refused *as* not-found), or the reply
/// carries no capability.
///
/// The path is spoken exactly once, here. Everything afterwards is integers
/// in the packet header.
pub fn open(path: []const u8) ?Channel {
return .{ .endpoint = openPath(path) orelse return null };
}
/// Establish a channel by contract name — `open` with `/protocol/` supplied,
/// which is how every caller in the system spells it.
pub fn connect(name: []const u8) ?Channel {
return .{ .endpoint = openEndpoint(name) orelse return null };
}
/// Send one request packet and block for the reply: `[Header][request]` out,
/// `[Status][reply]` back. `request` is the bytes *after* the header — the
/// protocol's fixed part plus any tail — because the header is this call's
/// to lay down. The reply's payload lands in `into`.
///
/// Null means the transport failed, which today means one of: a dead
/// provider (`-EPEER` — discard this channel and `open` the name again), an
/// oversized packet, or a bad handle. A provider that answered *and refused*
/// is not a failure here: it comes back with a negative `Response.status`.
pub fn call(self: Channel, header: envelope.Header, request: []const u8, into: []u8) ?Response {
return self.callCapability(header, request, into, null);
}
/// As `call`, handing the provider a capability with the request — the only
/// direction-crossing move kernel-ipc offers, and how `subscribe` delivers
/// the subscriber's own endpoint.
pub fn callCapability(
self: Channel,
header: envelope.Header,
request: []const u8,
into: []u8,
capability: ?ipc.Handle,
) ?Response {
var packet: [envelope.packet_maximum]u8 = undefined;
const framed = frame(header, request, &packet) orelse return null;
var reply: [envelope.packet_maximum]u8 = undefined;
const answer = ipc.callCap(self.endpoint, framed, &reply, capability) catch return null;
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
const available = @min(answer.len - envelope.prefix_size, @as(usize, status.len));
const taken = @min(available, into.len);
@memcpy(into[0..taken], reply[envelope.prefix_size..][0..taken]);
return .{ .status = status, .payload = into[0..taken], .capability = answer.cap };
}
/// Push one event packet and return immediately — no reply owed, and a slow
/// or dead peer can never stall the sender. Bounded by `post_maximum`: an
/// event that does not fit is refused here rather than split, because a
/// packet is never fragmented.
pub fn send(self: Channel, header: envelope.Header, payload: []const u8) bool {
var packet: [envelope.post_maximum]u8 = undefined;
const framed = frame(header, payload, &packet) orelse return false;
return ipc.send(self.endpoint, framed);
}
/// Ask the provider what it is: the reserved `describe` verb, answered by
/// every protocol built through `envelope.Define`. The name and version come
/// back in `into`, which the returned `Described` borrows.
pub fn describe(self: Channel, into: []u8) ?envelope.Described {
var request: [envelope.packet_maximum]u8 = undefined;
const packet = envelope.encodeDescribe(&request) orelse return null;
var reply: [envelope.packet_maximum]u8 = undefined;
const answer = ipc.callCap(self.endpoint, packet, &reply, null) catch return null;
const taken = @min(answer.len, into.len);
@memcpy(into[0..taken], reply[0..taken]);
return envelope.decodeDescribe(into[0..taken]);
}
/// Drop the provider's endpoint and free the handle-table slot. The
/// conversation is over the moment the capability is gone — there is nothing
/// else holding it open.
pub fn close(self: Channel) void {
_ = ipc.close(self.endpoint);
}
};
// --- the namespace: resolving, opening, and claiming a contract name ---------
/// Where a `/protocol/...` path routed: the registry's endpoint, plus the path
/// rewritten mount-relative (`/display` for `/protocol/display`). The handle is
/// deduplicated by the kernel across resolves and shared with every other user
/// of that mount, so it is never ours to close.
const Registry = struct {
handle: ipc.Handle,
relative: [path_maximum]u8,
relative_len: usize,
fn path(self: *const Registry) []const u8 {
return self.relative[0..self.relative_len];
}
};
/// Route `path` to whatever backend serves it. Null when nothing is mounted
/// there — under `/protocol` that means the registry is not up yet, which is a
/// *retry*, not a refusal. A kernel-served route (the read-only `/system` tree)
/// is the wrong path, not a channel, and is refused here.
fn reach(path: []const u8) ?Registry {
var out: Registry = .{ .handle = 0, .relative = undefined, .relative_len = 0 };
const route = file_system.fsResolve(path, 0, &out.relative) orelse return null;
switch (route) {
.kernel => return null,
.backend => |b| {
out.handle = b.handle;
out.relative_len = b.path_len;
return out;
},
}
}
/// 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,
request: vfs_protocol.Protocol.RequestOf(operation),
name: []const u8,
send_capability: ?ipc.Handle,
) ?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, 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(.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;
}
/// The provider's raw endpoint behind `/protocol/<name>`. The transitional form,
/// for the clients that still marshal their protocol's bytes by hand; P4 moves
/// them onto `Channel` proper and this shrinks back to `connect`.
///
/// Null covers both "no such contract" and "you may not have it" — deliberately
/// the same answer (protocol-namespace.md: enforcement is absence), and also
/// "the registry is not mounted yet", which is why every caller retries.
pub fn openEndpoint(name: []const u8) ?ipc.Handle {
var path: [path_maximum]u8 = undefined;
const full = join(name, &path) orelse return null;
return openPath(full);
}
/// Claim `/protocol/<name>` for `endpoint`: the registry records the name
/// against this process and hands the endpoint to whoever opens it afterwards.
/// The endpoint rides the call as its capability, the one direction-crossing
/// move kernel-ipc offers.
///
/// Three-valued on purpose. **Null** is "the registry could not be reached" —
/// it is not mounted yet, which happens when a provider starts before init has
/// finished coming up, and the answer is to retry. A **value** is the registry's
/// verdict and is final: 0 bound, `-EPERM` this binary is not granted that name,
/// `-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(.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
/// init, which mounts `/protocol` before it spawns anyone, so in a normal boot
/// the first try lands; a provider the kernel test harness starts may well beat
/// init to the mount, which is what the patience is for. Four seconds of 20 ms
/// tries — the same cadence every client in the tree spends finding a service.
const bind_attempts: u32 = 200;
const bind_retry_ms: u64 = 20;
/// `bind`, waiting out a registry that is not mounted yet. Only unreachability
/// is retried: a registry that *answered* has decided, and asking again cannot
/// change its mind. True when the name is ours.
pub fn bindPatiently(name: []const u8, endpoint: ipc.Handle) bool {
var attempt: u32 = 0;
while (attempt < bind_attempts) : (attempt += 1) {
if (bind(name, endpoint)) |status| return status == 0;
time.sleepMillis(bind_retry_ms);
}
return false;
}
/// `/protocol/` + `name`, in the caller's buffer. Null if the name is empty or
/// longer than the namespace admits.
fn join(name: []const u8, buffer: []u8) ?[]u8 {
if (name.len == 0 or name.len > name_maximum) return null;
const total = root.len + 1 + name.len;
if (total > buffer.len) return null;
@memcpy(buffer[0..root.len], root);
buffer[root.len] = '/';
@memcpy(buffer[root.len + 1 ..][0..name.len], name);
return buffer[0..total];
}
/// Lay a packet down: the folded header first, then the protocol's bytes. Null
/// when it would not fit the buffer — the same rule as `envelope`'s framing,
/// applied where the buffer is the transport's, not the protocol's.
fn frame(header: envelope.Header, body: []const u8, buffer: []u8) ?[]u8 {
const total = envelope.prefix_size + body.len;
if (total > buffer.len) return null;
@memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header));
@memcpy(buffer[envelope.prefix_size..][0..body.len], body);
return buffer[0..total];
}
// --- tests ------------------------------------------------------------------
//
// The syscall half cannot run on the host, and there is no registry to reach
// until P2 — so what is testable here is the framing, which is the part with
// arithmetic in it.
const testing = std.testing;
test "a framed packet is the header followed by the protocol's bytes" {
var buffer: [envelope.packet_maximum]u8 = undefined;
const header = envelope.Header{ .operation = envelope.first_protocol_operation, .target = 9 };
const packet = frame(header, "body", &buffer).?;
try testing.expectEqual(envelope.prefix_size + "body".len, packet.len);
const decoded = envelope.headerOf(packet).?;
try testing.expectEqual(envelope.first_protocol_operation, decoded.operation);
try testing.expectEqual(@as(u64, 9), decoded.target);
try testing.expectEqualStrings("body", packet[envelope.prefix_size..]);
}
test "a contract name joins the namespace root exactly once" {
var buffer: [path_maximum]u8 = undefined;
try testing.expectEqualStrings("/protocol/display", join("display", &buffer).?);
try testing.expectEqualStrings("/protocol/test/shared-memory", join("test/shared-memory", &buffer).?);
try testing.expect(join("", &buffer) == null);
try testing.expect(join("x" ** (name_maximum + 1), &buffer) == null);
}
test "framing refuses a packet that would not fit rather than truncating it" {
var post: [envelope.post_maximum]u8 = undefined;
const header = envelope.Header{ .operation = envelope.first_protocol_operation };
const body = [_]u8{0} ** (envelope.post_maximum - envelope.prefix_size);
const one_too_many = body ++ [_]u8{0};
try testing.expect(frame(header, &body, &post) != null);
try testing.expect(frame(header, &one_too_many, &post) == null);
}
+55 -53
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;
@@ -39,6 +44,7 @@ fn kindFromWire(value: u32) Kind {
@intFromEnum(Kind.symbolic_link) => .symbolic_link,
@intFromEnum(Kind.fifo) => .fifo,
@intFromEnum(Kind.socket) => .socket,
@intFromEnum(Kind.protocol) => .protocol,
else => .regular,
};
}
@@ -88,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.
@@ -124,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
@@ -138,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
@@ -168,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 };
}
@@ -180,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);
}
};
@@ -192,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 };
},
}
}
@@ -247,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;
@@ -279,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
@@ -335,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`;
+54 -11
View File
@@ -29,10 +29,10 @@ pub fn createIpcEndpoint() ?Handle {
return if (failed(r)) null else r;
}
/// Publish endpoint `h` under a well-known service id so other processes find it.
pub fn register(id: abi.ServiceId, h: Handle) bool {
return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h));
}
// `register`/`lookup` lived here — the two wrappers over the flat ServiceId
// registry. Naming is not a system call any more: a provider binds its contract
// name at the registry and a client resolves and opens `/protocol/<name>`, both
// through `channel` (docs/os-development/protocol-namespace.md).
/// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table
/// slot. A forwarding hop closes a cap it passed on; a binder closes a DMA-region cap
@@ -42,13 +42,6 @@ pub fn close(h: Handle) bool {
return !failed(sc.systemCall1(.handle_close, h));
}
/// Find the endpoint published under `id`, installing a handle to it in this
/// process.
pub fn lookup(id: abi.ServiceId) ?Handle {
const r = sc.systemCall1(.ipc_lookup, @intFromEnum(id));
return if (failed(r)) null else r;
}
pub const CallError = error{Failed};
/// The result of a capability-passing `callCap`: the reply length, and the handle of
@@ -178,6 +171,56 @@ pub const Received = struct {
}
};
/// A capability that arrived with one turn of a receive loop, and the ownership
/// rule for it: **the turn owns it until a handler takes it, and closes whatever
/// is left.**
///
/// The kernel installs a sent capability in the receiver's handle table whenever
/// the caller attached one, *independent of the message's length or kind*
/// (system/kernel/ipc-synchronous.zig `replyWait`), so every path out of a loop
/// has to dispose of one — including the paths that never look at the message.
/// The table is thirty-two slots, and `ipc_call` does not dedupe, so a client
/// looping on `callCap(server, &.{}, endpoint)` spends one slot per call: about
/// thirty-two zero-length pings and the service can never accept another
/// capability, which means no subscribe and no shared-memory handover, for the
/// rest of the boot. It is unauthenticated and it is two lines to write.
///
/// So ownership is structural rather than a close per branch — the per-branch
/// version has already failed twice in this tree, in PID 1's ping path and in
/// every `service.run` callback that simply ignored its capability argument.
/// Written this way, forgetting **closes**, and *keeping* a capability is the
/// thing a handler has to say out loud:
///
/// ```zig
/// var arrived: ipc.Arrival = .{ .handle = got.cap };
/// defer arrived.release(); // every exit path, including `continue`
/// ...
/// const kept = arrived.take().?; // claimed: mine to hold or close
/// ```
pub const Arrival = struct {
handle: ?Handle = null,
/// Look without claiming — a handler that may still refuse wants no close of
/// its own on the refusal paths.
pub fn peek(self: *const Arrival) ?Handle {
return self.handle;
}
/// Claim ownership: from here the capability is the taker's to keep or close,
/// and the turn will not touch it.
pub fn take(self: *Arrival) ?Handle {
defer self.handle = null;
return self.handle;
}
/// Close whatever nobody claimed. Idempotent, so it is safe as a `defer` next
/// to any number of `take`s.
pub fn release(self: *Arrival) void {
if (self.handle) |handle| _ = close(handle);
self.handle = null;
}
};
/// Server side of IPC_ReplyWait: deliver `reply` to the client last received (if any,
/// optionally handing it `send_cap`), then block until the next request arrives in
/// `receive`. Returns its length, the sender badge, and any capability the request
+12
View File
@@ -142,6 +142,18 @@ pub fn subscribeExits(endpoint: usize) bool {
/// snapshot buffer without importing `abi` itself.
pub const ProcessDescriptor = abi.ProcessDescriptor;
/// The calling task's own kernel id — its row in the process table, and the value
/// every other process sees as this one's `supervisor` after it spawns them. For a
/// single-threaded program that is its process id; in a threaded one it is the
/// calling thread's id (`Thread.getCurrentId` is the same system call, named for
/// the threading vocabulary). Ids are monotonic and never reused
/// (system/kernel/process.zig), which is what makes comparing one an identity
/// test where comparing a *name* is only a resemblance test — the registrar in
/// init leans on exactly that.
pub fn taskId() u32 {
return @intCast(sc.systemCall0(.thread_self));
}
/// Give up the rest of this quantum.
pub fn yield() void {
_ = sc.systemCall0(.yield);
+288 -16
View File
@@ -6,26 +6,64 @@
//! loop chose, never on a hijacked stack — the whole reason signals are
//! messages.
//!
//! One rule a service author does have to know, and it is stated on
//! `Callbacks.on_message`: **a capability that arrives belongs to the turn** —
//! the loop closes it unless the callback claims it with `take()`. Forgetting is
//! therefore safe, and keeping is explicit; the opposite arrangement quietly
//! spends a handle-table slot per request.
//!
//! The harness also owns the **subscriber side** of a protocol that declares
//! `.events` — see `Subscribers`. The table, the reserved subscribe/unsubscribe
//! verbs, the fan-out, and the dead-subscriber sweep live here rather than in
//! each provider, so every event stream in the system has identical semantics
//! (docs/os-development/protocol-namespace.md, "Wiring").
//!
//! The liveness probe: a **zero-length request is the universal ping**, answered
//! with a zero-length reply by the harness itself. No protocol's requests start
//! at length zero, so the encoding cannot collide, and there is nothing for a
//! service author to implement — a wedged service simply fails to answer, which
//! is the diagnosis (see docs/ipc.md).
const abi = @import("abi");
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const process = @import("process");
/// The harness's handle on a provider's subscriber table, type-erased because
/// `run` is not generic over the protocol while `Subscribers` is. A service names
/// its table once, as `Callbacks.subscribers`, and the loop does the rest: it
/// subscribes to published process exits at startup and drops a dead task's
/// subscriptions before the service's own notification callback ever sees the
/// badge.
pub const SubscriberHooks = struct {
/// Ask the kernel for published exit events on this service's endpoint.
watch: *const fn (endpoint: ipc.Handle) void,
/// Drop everything task `dead` had subscribed.
forget: *const fn (dead: u32) void,
};
pub const Callbacks = struct {
/// Called once with the service's endpoint before the loop starts — the
/// place to subscribe to exit events, bind IRQs, or announce readiness.
/// Return false to abort startup (the process exits).
init: ?*const fn (endpoint: ipc.Handle) bool = null,
/// One protocol request from `sender` (a task id): write the reply into
/// `reply`, return its length. `capability` is the handle the request
/// carried, if any (M13 cap passing — how a subscriber hands over its
/// endpoint). The zero-length ping never reaches this.
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize,
/// `reply`, return its length. The zero-length ping never reaches this.
///
/// `arrived` is the capability the request carried (M13 cap passing — how a
/// subscriber hands over its endpoint), and it comes with **an ownership
/// rule: the turn owns it, and a handler that wants to keep it must say so
/// with `take()`.** Whatever is left when this returns, the loop closes.
/// `peek()` reads it without claiming, which is what a handler that may
/// still refuse wants — no close of its own on the refusal paths.
///
/// The rule is stated here, in the contract, because the alternative has
/// failed in practice: an implementation that simply ignored a `?ipc.Handle`
/// argument leaked a handle table slot per request, and every operation
/// except a subscribe ignores it. Thirty-two such requests — zero-length
/// pings will do, and they need no authorization — and the service can never
/// accept another capability for the rest of the boot. See `ipc.Arrival`.
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize,
/// A notification that is not a signal — a subscribed exit event, a bound
/// IRQ, a timer landing. The raw badge; decode with the ipc helpers.
on_notification: ?*const fn (badge: u64) void = null,
@@ -35,21 +73,238 @@ pub const Callbacks = struct {
/// the return itself — never put *necessary* work here (iron rule 1: a kill
/// arrives with no warning; this is for graceful extras only).
on_terminate: ?*const fn () void = null,
/// Publish the endpoint under a well-known service id at startup.
service: ?abi.ServiceId = null,
/// The contract this service provides: a name under `/protocol`, mirroring
/// the `library/protocol/` module that defines the wire format — a program
/// imports `display-protocol` and the provider binds `"display"`
/// (docs/os-development/protocol-namespace.md). Bound at startup, before
/// `init` runs, so the service is reachable the moment it serves. A refusal
/// (not granted, or a live provider already holds the name) aborts startup.
service: ?[]const u8 = null,
/// This provider's subscriber table — `Subscribers(Protocol, Context).hooks`
/// — for a protocol that declares `.events`. Naming it here is what buys the
/// exit-notification sweep: the loop subscribes to published deaths at
/// startup and releases a dead subscriber's slot (and the endpoint capability
/// in it) when one lands.
subscribers: ?SubscriberHooks = null,
};
/// Run the service: create and (optionally) register the endpoint, bind signals
/// to it, call `init`, then serve until `terminate` arrives — at which point the
/// loop returns and main's return is the clean exit the supervisor reads as
/// `ExitReason.exited`. `maximum_message` sizes the receive and reply buffers
/// (a service passes its protocol's message maximum).
/// How many subscribers one provider fans out to. Bounded like every table in
/// this system; a subscribe past the end is refused with `-ENOSPC` rather than
/// silently forgetting an earlier one.
pub const subscriber_capacity = 8;
/// The interest mask that means "every event of this protocol" — what a
/// subscriber which named no class gets, and what a provider passes when the
/// event it is publishing belongs to no class.
pub const every_event: u32 = 0;
/// The subscriber side of a protocol, for a provider whose contract declares
/// `.events` (docs/os-development/protocol-namespace.md: *the harness owns the
/// machinery — the subscriber table, the dead-subscriber sweep, and the fan-out
/// loop*). Three services hand-rolled this, with three different ideas of when a
/// dead subscriber goes away — a poll of the process list on subscribe, a drop on
/// a failed send, and nothing at all. This is the one idiom.
///
/// ```zig
/// const Subscriptions = service.Subscribers(power_protocol.Protocol, void);
/// ...
/// fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
/// return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply);
/// }
/// pub fn main() void {
/// service.run(power_protocol.message_maximum, .{
/// .service = "power",
/// .on_message = onMessage,
/// .subscribers = Subscriptions.hooks,
/// });
/// }
/// ```
///
/// What the provider still writes is its own events — `publish(.power_button, 0,
/// .{})`. Everything else happens here: registering the caller's endpoint on the
/// reserved `subscribe` verb, taking that capability out of the turn, dropping it
/// on `unsubscribe` or on the subscriber's death, and framing one packet for the
/// whole fan-out.
///
/// The table is per instantiation (a container-level `var` inside the generic
/// type), so a process providing two contracts gets two tables and neither can
/// see the other's subscribers.
pub fn Subscribers(comptime Protocol: type, comptime Context: type) type {
return struct {
/// The generated dispatch this provider answers with.
pub const Provider = Protocol.Provider(Context);
pub const Handlers = Provider.Handlers;
/// One registered subscriber: the endpoint events are pushed to (the
/// capability it handed over at subscribe time, which this slot owns),
/// the task that handed it over — the kernel-stamped badge, the only
/// source identity there is — and which classes of event it asked for.
const Slot = struct {
used: bool = false,
endpoint: ipc.Handle = 0,
task: u32 = 0,
interest: u32 = every_event,
};
var slots: [subscriber_capacity]Slot = .{Slot{}} ** subscriber_capacity;
/// Set when a slot has taken the capability the turn carried, and read
/// back in `dispatch`, 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 claimed = false;
/// What `Callbacks.subscribers` is given.
pub const hooks: SubscriberHooks = .{ .watch = watchExits, .forget = forget };
fn watchExits(endpoint: ipc.Handle) void {
// Published exits, not a poll of the process list: a service must
// never depend on clients cleaning up after themselves, and it must
// not have to walk the whole table on every subscribe to find out
// either (docs/process-lifecycle.md, "Who learns of a death").
_ = process.subscribeExits(endpoint);
}
/// Release everything task `dead` had subscribed. The slot owns the
/// endpoint capability, so reclaiming the slot closes it — otherwise a
/// process that subscribes and dies costs a handle-table slot that never
/// comes back.
pub fn forget(dead: u32) void {
for (&slots) |*slot| {
if (slot.used and slot.task == dead) {
_ = ipc.close(slot.endpoint);
slot.* = .{};
}
}
}
/// Whether `task` is a subscriber — the gate for an operation a provider
/// honours from its subscribers and nobody else. The power service's
/// shutdown is the one: the badge is kernel-stamped, so nothing in a
/// packet can claim to be the subscriber that already ran the stop
/// sequence.
pub fn has(task: u32) bool {
for (&slots) |*slot| {
if (slot.used and slot.task == task) return true;
}
return false;
}
/// Answer one received packet, with the reserved `subscribe` and
/// `unsubscribe` verbs already wired — a provider that leaves those two
/// handlers null (every provider should) gets the harness's. The turn's
/// capability is peeked, never taken, unless a slot actually kept it.
pub fn dispatch(
context: Context,
handlers: Handlers,
packet: []const u8,
sender: u32,
arrived: *ipc.Arrival,
reply: []u8,
) usize {
var wired = handlers;
if (wired.subscribe == null) wired.subscribe = onSubscribe;
if (wired.unsubscribe == null) wired.unsubscribe = onUnsubscribe;
claimed = false;
const written = Provider.dispatch(context, wired, packet, sender, arrived.peek(), reply);
if (claimed) _ = arrived.take();
return written;
}
/// Push one event to every subscriber.
pub fn publish(
comptime event: Protocol.Event,
target: u64,
payload: Protocol.PayloadOf(event),
) void {
publishClass(event, target, payload, every_event);
}
/// Push one event to the subscribers whose interest mask includes
/// `class` (a subscriber that named no class takes everything). The
/// packet is framed **once**, outside the loop, so every subscriber of a
/// class receives identical bytes; and delivery is `ipc.send`, which
/// never blocks, so one slow or dead subscriber can never stall the rest
/// — the whole reason broadcast is a provider pattern and not a kernel
/// primitive.
pub fn publishClass(
comptime event: Protocol.Event,
target: u64,
payload: Protocol.PayloadOf(event),
class: u32,
) void {
var packet: [envelope.post_maximum]u8 = undefined;
const framed = Protocol.encodeEvent(event, target, payload, &packet) orelse return;
for (&slots) |*slot| {
if (!slot.used) continue;
if (!wants(slot.*, class)) continue;
// The sweep is what normally reclaims a dead subscriber, promptly
// and with its capability closed. This is the backstop for a
// notification that never arrived: an endpoint's notify ring is
// bounded, so a burst of deaths can drop one, and a send to an
// endpoint whose owner is gone fails rather than blocking.
if (!ipc.send(slot.endpoint, framed)) {
_ = ipc.close(slot.endpoint);
slot.* = .{};
}
}
}
fn wants(slot: Slot, class: u32) bool {
if (class == every_event) return true; // the event belongs to no class
if (slot.interest == every_event) return true; // the subscriber named none
return slot.interest & class != 0;
}
/// The reserved `subscribe` verb: register the caller's endpoint (the
/// call's capability) for the classes its tail names. A refusal simply
/// returns and the turn closes what arrived — the harness's ownership
/// rule (`ipc.Arrival`), which is why a subscribe storm against a full
/// table cannot spend the handle table.
fn onSubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed
const interest = envelope.decodeSubscribe(invocation.tail).interest;
for (&slots) |*slot| {
if (slot.used) continue;
// Appended, not replaced: one task may hold several subscriptions
// on different endpoints (a client taking keyboard and mouse as
// two streams), and each is its own conversation.
slot.* = .{ .used = true, .endpoint = endpoint, .task = invocation.sender, .interest = interest };
claimed = true; // the table holds it until that task dies
return 0;
}
return -envelope.ENOSPC; // table full
}
/// The reserved `unsubscribe` verb: every subscription the calling task
/// holds here goes, which is exactly what its death would do. It names no
/// endpoint because the badge already names the only subscriber a caller
/// can speak for — its own.
fn onUnsubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
if (!has(invocation.sender)) return -envelope.ENOENT;
forget(invocation.sender);
return 0;
}
};
}
/// Run the service: create the endpoint, bind it under the service's contract
/// name (if it has one), bind signals to it, call `init`, then serve until
/// `terminate` arrives — at which point the loop returns and main's return is
/// the clean exit the supervisor reads as `ExitReason.exited`.
/// `maximum_message` sizes the receive and reply buffers (a service passes its
/// protocol's message maximum).
pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
const endpoint = ipc.createIpcEndpoint() orelse return;
if (callbacks.service) |id| {
if (!ipc.register(id, endpoint)) return;
if (callbacks.service) |name| {
if (!channel.bindPatiently(name, endpoint)) return;
}
_ = process.bindSignals(endpoint);
// Before `init`, so a subscriber that arrives the instant the name is bound
// is already covered by the sweep that will release it.
if (callbacks.subscribers) |subscribers| subscribers.watch(endpoint);
if (callbacks.init) |initialise| {
if (!initialise(endpoint)) return;
}
@@ -59,6 +314,16 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
var receive: [maximum_message]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 a callback claims it (`ipc.Arrival`). Structural
// rather than a close per branch, because the branches are exactly what
// gets forgotten: the ping's `continue` below, and every `on_message`
// that has no use for a capability — which is every operation but a
// subscribe. A `defer` in a loop body runs on `continue` and on the
// `return` that ends the loop, so this covers all four exits.
var arrived: ipc.Arrival = .{ .handle = got.cap };
defer arrived.release();
if (got.isNotification()) {
reply_len = 0; // nothing owed for a notification
if (process.signalsFrom(got.badge)) |signals| {
@@ -71,13 +336,20 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
}
continue;
}
// A death sweeps the subscriber table first, then still reaches the
// service: a provider often has its own per-client state to release
// (open file handles, device tokens, layers) and the same badge is
// the notice for both.
if (got.isChildExit()) {
if (callbacks.subscribers) |subscribers| subscribers.forget(got.childProcessId());
}
if (callbacks.on_notification) |onNotification| onNotification(got.badge);
continue;
}
if (got.len == 0) {
reply_len = 0; // the universal ping: a zero-length reply, from the harness
continue;
continue; // any capability it carried goes out through the turn's `defer`
}
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), got.cap);
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
}
}
+49 -39
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;
+29 -1
View File
@@ -3,6 +3,11 @@
//! 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,
//! and therefore the one module every other one imports:
//!
//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md)
//!
//! vfs-protocol : the VFS server <-> the file layer (unistd/stdio)
//! input-protocol : the input fan-out service <-> sources + subscribers
//! block-protocol : a filesystem <-> a block driver (usb-storage)
@@ -15,6 +20,11 @@
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 }{
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
.{ .name = "input-protocol", .root = "input/input-protocol.zig" },
@@ -25,20 +35,38 @@ 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{
"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
"device-manager/device-manager-protocol.zig", // the dual-use report, exactly on the push floor
"power/power-protocol.zig", // the event kind as the packet's verb
"usb-transfer/usb-transfer-protocol.zig", // the tail-carried control stage + the trimmed report
}) |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);
@@ -1,15 +1,48 @@
//! The device-manager protocol (docs/device-manager.md): what drivers and
//! applications say to the device manager over its well-known endpoint. The
//! vfs-protocol pattern — extern-struct messages, a version in the handshake,
//! reserved fields — so both sides depend on the contract by name. Deliberately
//! contains nothing lifecycle-shaped: stopping, liveness (the zero-length ping),
//! and exit reasons are the universal vocabulary of
//! The device-manager protocol (docs/device-driver-development/device-manager.md):
//! what drivers and applications say to the device manager over
//! `/protocol/device-manager`. Defined through the envelope
//! (docs/os-development/protocol-namespace.md), so every packet — request, reply,
//! and pushed event alike — begins with the folded `Header`.
//!
//! **`Header.target` is the device id.** It was the `device_id` field of three
//! different messages; folding it into the header is what made the packed
//! leading operation byte disappear along with it. `no_device` addresses a
//! driver that serves no enumerated device.
//!
//! Two of the manager's four old operations were the reserved verbs under
//! another name and are gone from this protocol's own numbering: `enumerate`
//! (the tree, one `ChildEntry` per record in the reply tail) and `subscribe`
//! (the watcher's endpoint rides as the call's capability). What is left is the
//! driver-facing half — the handshake and the two tree reports.
//!
//! **`ChildAdded` travels in both directions, and says so twice.** A bus driver
//! *calls* `child_added` to report a device; the manager then *pushes* the same
//! struct to every subscriber as the `child_added` event. Operations and events
//! are numbered in separate spaces, so one struct under two numbers is exactly
//! how the envelope spells "one encoding, both directions" — and the direction
//! (call vs. send) already tells them apart.
//!
//! Deliberately contains nothing lifecycle-shaped: stopping, liveness (the
//! zero-length ping), and exit reasons are the universal vocabulary of
//! docs/process-lifecycle.md, not this protocol.
/// The protocol version a driver states in its hello. A manager that cannot
/// serve a driver's version refuses the hello, and the mismatch is loud at
/// startup instead of quiet corruption later.
pub const version: u16 = 1;
const std = @import("std");
const envelope = @import("envelope");
/// The protocol version a driver states in its hello, and the version this
/// contract answers `describe` with. A manager that cannot serve a driver's
/// version refuses the hello, and the mismatch is loud at startup instead of
/// quiet corruption later.
///
/// `describe` publishes the same number, but it cannot replace this: it tells a
/// *client* what the provider is, and here it is the **provider** that has to
/// learn what the client was built against in order to refuse it.
pub const version = 1;
/// `Header.target` for a driver that serves no enumerated device (a test
/// fixture, a synthetic source), and `ChildAdded`'s answer for a leaf that was
/// never `device_register`ed.
pub const no_device: u64 = ~@as(u64, 0);
/// Which bus a `child_added` came from — stated by the reporting bus driver so
/// the manager's /system/configuration/devices.csv matcher knows how to read the report's identity
@@ -24,7 +57,7 @@ pub const BusKind = enum(u8) {
acpi = 3,
};
/// What kind of driver is talking (docs/driver-model.md's shapes).
/// What kind of driver is talking (docs/device-driver-development/driver-model.md's shapes).
pub const Role = enum(u8) {
/// Owns a controller and reports the devices behind it (`child_added`).
bus = 1,
@@ -32,58 +65,45 @@ pub const Role = enum(u8) {
device = 2,
};
/// The message kinds.
pub const Operation = enum(u8) {
hello = 1,
child_added = 2,
child_removed = 3,
enumerate = 4,
subscribe = 5,
};
/// `Hello.device_id` for a driver that serves no enumerated device (a test
/// fixture, a synthetic source).
pub const no_device: u64 = ~@as(u64, 0);
// --- the per-operation request parts ----------------------------------------
//
// Each names the bytes AFTER the prefix. Nothing here carries an operation or a
// device id: those are the packet header's, folded in once. No reply part
// carries a status either — that is the `Status` every reply already begins
// with, so the manager's old three `{status, reserved}` reply structs are gone.
/// The handshake, sent once by every driver the manager spawns — the manager's
/// one self-enforced deadline: spawned and silent past it means wrong binary,
/// wrong version, or wedged before main, and the stop sequence follows.
/// wrong version, or wedged before main, and the stop sequence follows. The
/// device this driver was assigned (its argv[1]) is `Header.target`.
pub const Hello = extern struct {
operation: u8 = @intFromEnum(Operation.hello),
/// A Role value.
/// A `Role` value.
role: u8,
_padding: u8 = 0,
/// The protocol version this driver was built against (`version`).
version: u16 = version,
reserved: u32 = 0,
/// The device this driver was assigned (its argv[1]), or `no_device`.
device_id: u64,
};
pub const hello_size = @sizeOf(Hello);
/// The manager's answer to a hello. Nonzero status = refused (version mismatch,
/// unknown sender); a refused driver should exit cleanly.
pub const HelloReply = extern struct {
status: i32,
reserved: u32 = 0,
};
pub const reply_size = @sizeOf(HelloReply);
/// A bus driver reporting one device it discovered behind its controller
/// (docs/device-manager.md "the tree"). Identity is the bus's native language —
/// for USB a port-speed class; the (class, subclass, protocol) triple joins it
/// once control transfers exist (the USB track). The manager mirrors the child
/// into its tree; when the reporting driver dies, the manager prunes everything
/// it reported (the children describe protocol state that died with it) and the
/// restarted instance rediscovers and re-reports.
/// (docs/device-driver-development/device-manager.md "the tree"), and the payload
/// the manager pushes to its subscribers for the same event. Identity is the
/// bus's native language — for USB a port-speed class, for PCI the class triple.
/// The manager mirrors the child into its tree; when the reporting driver dies,
/// the manager prunes everything it reported (the children describe protocol
/// state that died with it) and the restarted instance rediscovers and
/// re-reports.
///
/// `Header.target` is the kernel device id this child was `device_register`ed
/// as — what the manager hands a matched driver as its argv assignment — or
/// `no_device` for an unregistered leaf (a USB port before the descriptor
/// track). That is the field that used to sit at the end of this struct.
///
/// **The field order is the size budget.** An event packet is the header plus
/// this, within 64 bytes, and three `u64`s round the whole struct up to a
/// multiple of eight whatever order they sit in — so the small fields are
/// packed tail-first into the space the rounding pays for anyway. `Define`
/// checks the result; this comment is why there is no slack in it.
pub const ChildAdded = extern struct {
operation: u8 = @intFromEnum(Operation.child_added),
/// A `BusKind` value: which bus reported this child, so the manager reads the
/// identity in the right namespace and matches against the right `bus` column.
bus: u8 = @intFromEnum(BusKind.unknown),
reserved1: u16 = 0,
reserved2: u32 = 0,
/// The reporting driver's own device (the controller) — the child's parent.
parent: u64,
/// Where on the bus (for USB: the root port number, 1-based).
@@ -91,10 +111,10 @@ pub const ChildAdded = extern struct {
/// Bus-specific identity (for USB: the PORTSC port-speed class; for PCI:
/// the class triple; for ACPI devices, 0 — identity is the hid below).
identity: u64,
/// The kernel device id this child was `device_register`ed as — what the
/// manager hands a matched driver as its argv assignment — or `no_device`
/// for an unregistered leaf (a USB port before the descriptor track).
device_id: u64 = no_device,
/// The PCI subsystem id, packed `(subsystem_vendor << 16) | subsystem_device`
/// (so it reads vendor-first, matching the CSV's `ssvid:ssid`), or 0 when the
/// device has no subsystem id (a bridge, or a non-PCI bus).
subsystem: u32 = 0,
/// The vendor id (PCI vendor / USB idVendor), or 0 when the bus has no such
/// concept (ACPI). Carried so the manager's /system/configuration/devices.csv matcher can bind
/// on vendor — a level the bus-native `identity` (a class triple) cannot express.
@@ -103,73 +123,118 @@ pub const ChildAdded = extern struct {
/// level: this is what lets one virtio-gpu (1AF4:1050) be told from any other
/// virtio display function without the driver re-confirming after it is spawned.
device: u16 = 0,
/// The PCI subsystem id, packed `(subsystem_vendor << 16) | subsystem_device`
/// (so it reads vendor-first, matching the CSV's `ssvid:ssid`), or 0 when the
/// device has no subsystem id (a bridge, or a non-PCI bus).
subsystem: u32 = 0,
/// The ACPI hardware id (`_HID`), EISA-decoded (e.g. "PNP0303"), for devices
/// discovered by firmware string rather than a numeric bus identity. Empty
/// (all zero) otherwise. Widens for FDT `compatible` strings later.
hid: [8]u8 = .{0} ** 8,
/// A `BusKind` value: which bus reported this child, so the manager reads the
/// identity in the right namespace and matches against the right `bus` column.
bus: u8 = @intFromEnum(BusKind.unknown),
_padding: [7]u8 = .{0} ** 7,
};
pub const child_added_size = @sizeOf(ChildAdded);
/// A bus driver reporting a device gone (hot-unplug). Not yet sent by any
/// driver — the port scan has no unplug interrupt — but the manager handles it;
/// death-pruning covers removal until hotplug lands.
/// A bus driver reporting a device gone (hot-unplug), and the payload pushed to
/// subscribers for it.
///
/// **This is the one message whose target stays 0.** A removal is addressed by
/// the composite (parent, bus address) — the reporter knows where the device
/// *was*, not necessarily what id it had been registered under — and a single
/// `u64` cannot carry a pair. So the address stays in the payload, where it
/// always was, and the header addresses the provider itself.
pub const ChildRemoved = extern struct {
operation: u8 = @intFromEnum(Operation.child_removed),
reserved0: u8 = 0,
reserved1: u16 = 0,
reserved2: u32 = 0,
parent: u64,
bus_address: u64,
};
pub const child_removed_size = @sizeOf(ChildRemoved);
/// The manager's answer to a tree report.
pub const ReportReply = extern struct {
status: i32,
reserved: u32 = 0,
};
/// An application asking for the tree (M18.3): the reply is an EnumerateReply
/// header followed by `count` ChildEntry records.
pub const Enumerate = extern struct {
operation: u8 = @intFromEnum(Operation.enumerate),
reserved0: u8 = 0,
reserved1: u16 = 0,
reserved2: u32 = 0,
};
pub const EnumerateReply = extern struct {
status: i32,
/// ChildEntry records following this header.
count: u32,
};
/// One record of the reserved `enumerate` reply: the manager's mirror, one
/// entry per known child, packed into the reply tail. The count is
/// `Status.len / @sizeOf(ChildEntry)` — the envelope's reply length says how
/// many arrived, so no count header is spent on saying it twice.
pub const ChildEntry = extern struct {
parent: u64,
bus_address: u64,
identity: u64,
};
/// An application subscribing to published add/remove events (the input-service
/// pattern): the subscriber's endpoint rides as the call's **capability**, and
/// events arrive on it as buffered messages whose payload is the same
/// ChildAdded / ChildRemoved struct the bus drivers send — one encoding, both
/// directions.
pub const Subscribe = extern struct {
operation: u8 = @intFromEnum(Operation.subscribe),
reserved0: u8 = 0,
reserved1: u16 = 0,
reserved2: u32 = 0,
};
/// How many `ChildEntry` records one `enumerate` reply can carry. Paging joins
/// the protocol if a tree ever outgrows one packet.
pub const entries_per_reply: usize = (envelope.packet_maximum - envelope.prefix_size) / @sizeOf(ChildEntry);
/// Upper bound on any message in this protocol — sizes the endpoint buffers.
/// Capped by the kernel's IPC MESSAGE_MAXIMUM (256): an EnumerateReply carries
/// up to ten ChildEntry records per call, plenty for the mirror's current
/// bounds; paging joins the protocol if a tree ever outgrows one message.
pub const message_maximum = 256;
pub const Protocol = envelope.Define(.{
.name = "device-manager",
.version = version,
.operations = &.{
// The driver-facing half. `enumerate` and `subscribe` are not here: they
// are the reserved verbs, which mean the same thing at every provider.
.{ .name = "hello", .request = Hello },
.{ .name = "child_added", .request = ChildAdded },
.{ .name = "child_removed", .request = ChildRemoved },
},
.events = &.{
// The watcher-facing half — the same two structs, pushed rather than
// called, in the events' own numbering space.
.{ .name = "child_added", .payload = ChildAdded },
.{ .name = "child_removed", .payload = ChildRemoved },
},
});
pub const Operation = Protocol.Operation;
pub const Event = Protocol.Event;
/// What the manager sizes its buffers to — the call floor, as every protocol does.
pub const message_maximum: usize = Protocol.message_maximum;
test "a tree report fits the push floor with the header folded in" {
// The dual-use struct is the tight one: `child_added` is both a call and an
// event, and the event floor is 64 bytes *including* the header. Forty-one
// bytes of content, rounded to 48 by the three u64s' alignment, plus the
// 16-byte header — exactly on the floor, which is what folding the operation
// byte and the device id out of the payload bought.
try std.testing.expectEqual(@as(usize, 48), @sizeOf(ChildAdded));
try std.testing.expectEqual(envelope.post_maximum, Protocol.event_maximum);
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
// Ten records per enumerate reply — what the old count-header layout carried.
try std.testing.expectEqual(@as(usize, 10), entries_per_reply);
}
test "the verb and event numbering, and the device id in the header" {
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.hello));
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.child_added));
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.child_removed));
// Events number in their own space, so the same two reports start at 16 too.
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.child_added));
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.child_removed));
// The manager's own enumerate/subscribe became the RESERVED verbs, below the
// protocol range entirely.
try std.testing.expectEqual(@as(u32, 1), envelope.operation_enumerate);
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
var buffer: [message_maximum]u8 = undefined;
const hello = Protocol.encodeRequest(.hello, 7, .{ .role = @intFromEnum(Role.bus) }, &.{}, &buffer).?;
try std.testing.expectEqual(@as(u64, 7), envelope.headerOf(hello).?.target);
try std.testing.expectEqual(@as(u16, 1), Protocol.decodeRequest(.hello, hello).?.version);
}
test "one struct, two numbers: the report a bus calls and the event a watcher is pushed" {
const report = ChildAdded{
.parent = 3,
.bus_address = 1,
.identity = 0x030000,
.bus = @intFromEnum(BusKind.pci),
.vendor = 0x1AF4,
};
var call: [message_maximum]u8 = undefined;
const called = Protocol.encodeRequest(.child_added, 42, report, &.{}, &call).?;
try std.testing.expectEqual(Operation.child_added, Protocol.operationOf(called).?);
try std.testing.expectEqual(@as(u64, 42), envelope.headerOf(called).?.target);
var push: [envelope.post_maximum]u8 = undefined;
const pushed = Protocol.encodeEvent(.child_added, 42, report, &push).?;
try std.testing.expectEqual(envelope.post_maximum, pushed.len);
try std.testing.expectEqual(Event.child_added, Protocol.eventOf(pushed).?);
try std.testing.expectEqual(@as(u16, 0x1AF4), Protocol.decodeEvent(.child_added, pushed).?.vendor);
// Same bytes after the prefix, different verb in it — the direction is what
// tells a call from a push, and the numbering spaces never collide.
try std.testing.expectEqualSlices(u8, called[envelope.prefix_size..], pushed[envelope.prefix_size..]);
}
+132 -76
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);
}
+974
View File
@@ -0,0 +1,974 @@
//! The envelope — the fixed prefix every danos packet begins with, and the
//! comptime `Define` that builds a protocol out of it. Layer L2 of
//! [communication.md](../../../docs/os-development/communication.md); the
//! authoritative description is
//! [protocol-namespace.md](../../../docs/os-development/protocol-namespace.md).
//!
//! This is the one module in the protocol domain that is not itself a protocol:
//! it is the shape every protocol is expressed in. A protocol module hands
//! `Define` its verbs and events and gets back numbered operations (never
//! colliding with the reserved range), typed encode/decode helpers, a
//! provider-side dispatch table that answers `describe` on its own — and, the
//! point of the exercise, compile-time proof that none of its packets can
//! exceed the transport floor. Errors that used to surface as runtime
//! truncation are compile errors, and "packets never fragment" is enforced at
//! the source rather than by review.
//!
//! **The prefix is folded, never stacked.** A `request`, `reply`, or `payload`
//! type names the bytes that follow the prefix — never the whole packet. The
//! verb and the object being addressed live in the prefix, so a protocol type
//! carries neither an `operation` field of its own nor a nested `Header`;
//! `rejectStacking` refuses one at compile time, and every size check adds
//! `prefix_size` exactly once.
const std = @import("std");
// --- the prefix -------------------------------------------------------------
/// Every packet a danos protocol transmits begins with this header — requests
/// on the synchronous call path, event packets on the asynchronous push path.
/// A reply spends the same 16 bytes on `Status` instead.
pub const Header = extern struct {
/// The verb. Values below `first_protocol_operation` are the reserved
/// universal verbs, which mean the same thing in every protocol.
operation: u32,
_padding: u32 = 0,
/// **Object** addressing within the peer, never party addressing: which of
/// the peer's objects this packet operates on — a volume, a layer, a node,
/// a device. `0` addresses the provider itself, and a protocol with no
/// objects never uses the field. *Which* party is at the other end was
/// decided once, when the channel was opened, and *who sent this* is the
/// kernel-stamped badge; neither is ever written here, which is what keeps
/// the source unforgeable.
target: u64 = 0,
};
/// Every reply begins with this. `len` counts the bytes that follow: the
/// reply's fixed part plus whatever variable tail the operation defines.
pub const Status = extern struct {
status: i32, // 0, or a negative errno
_padding: u32 = 0,
len: u32 = 0,
_padding2: u32 = 0,
};
/// The fixed prefix every packet spends — `Header` on a request or an event,
/// `Status` on a reply. One constant, because the two are deliberately the same
/// width: the packet budget does not depend on the direction.
pub const prefix_size: usize = @sizeOf(Header);
comptime {
if (@sizeOf(Header) != 16 or @sizeOf(Status) != 16)
@compileError("the envelope prefix is 16 bytes in both directions");
}
// --- the reserved verbs -----------------------------------------------------
/// Reserved verbs, answered by every provider. `Define` numbers a protocol's
/// own verbs from `first_protocol_operation`, so no protocol can reach in here.
pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds
pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page
pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint
pub const operation_unsubscribe: u32 = 3;
pub const first_protocol_operation: u32 = 16;
/// The optional body of a reserved `subscribe`: **which** of a provider's events
/// the subscriber wants, as a bit mask whose meaning the protocol defines (the
/// input service's device classes are the model). A reserved verb carries no
/// typed request, so this rides the packet's tail — and zero, which is also what
/// a subscribe that sent no body at all reads as, means *every* event.
///
/// The mask lives here rather than in each protocol because the subscriber
/// machinery is the service harness's (library/kernel/service.zig): the harness
/// records the number, the protocol decides what its bits mean, and neither has
/// to know the other.
pub const Subscription = extern struct { interest: u32 = 0 };
/// Frame a `subscribe` request. The subscriber's own endpoint travels as the
/// call's *capability*, never in the packet — that is what makes the reverse
/// path unforgeable.
pub fn encodeSubscribe(interest: u32, buffer: []u8) ?[]u8 {
const header = Header{ .operation = operation_subscribe };
const body = Subscription{ .interest = interest };
return frame(std.mem.asBytes(&header), std.mem.asBytes(&body), &.{}, buffer);
}
/// Frame a bare `unsubscribe`: it names no event and no endpoint, because it
/// means "every subscription this task holds here" (one task, one voice).
pub fn encodeUnsubscribe(buffer: []u8) ?[]u8 {
const header = Header{ .operation = operation_unsubscribe };
return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer);
}
/// The interest mask out of a `subscribe` packet's tail, on the provider's side.
/// A caller that sent no mask reads as the every-event mask.
pub fn decodeSubscribe(tail: []const u8) Subscription {
if (tail.len < @sizeOf(Subscription)) return .{};
return std.mem.bytesToValue(Subscription, tail[0..@sizeOf(Subscription)]);
}
/// The `describe` reply's fixed part, followed inline by `name_len` bytes of the
/// protocol's name. This is the version handshake: the version is asked for
/// once, at connect time, rather than re-carried by every packet out of a
/// 256-byte budget.
pub const Description = extern struct {
version: u32,
operation_count: u32,
event_count: u32,
name_len: u32,
};
/// Longest protocol name a `describe` reply can carry.
pub const name_maximum: usize = packet_maximum - prefix_size - @sizeOf(Description);
// --- the transport floor ----------------------------------------------------
/// The packet budget every protocol may assume on *any* transport. These are
/// the kernel-ipc transport's limits — `MESSAGE_MAXIMUM` and `POST_MAXIMUM` in
/// system/kernel/ipc-synchronous.zig — restated here because the kernel keeps
/// them private and a protocol has to compile against something. A fatter
/// transport raises its own ceiling; the floor does not move, so a protocol
/// that fits here fits everywhere (communication.md: ceilings are transport
/// properties, the floor is the protocol's contract).
pub const packet_maximum: usize = 256; // one request or one reply (ipc_call)
pub const post_maximum: usize = 64; // one event packet (ipc_send)
/// Whether a request or reply whose fixed part is `T` fits the call floor once
/// the prefix is counted. The folded rule in one line: `prefix_size` is added
/// exactly once, because `T` describes only what follows it. Exported so the
/// rule itself is testable — `Define` enforces it as a compile error.
pub fn fitsPacket(comptime T: type) bool {
return prefix_size + @sizeOf(T) <= packet_maximum;
}
/// The same, against the much smaller push floor an event packet lives within.
pub fn fitsPost(comptime T: type) bool {
return prefix_size + @sizeOf(T) <= post_maximum;
}
// --- reply statuses the envelope itself produces -----------------------------
/// Continued from the kernel's danos-native errno numbering
/// (system/kernel/ipc-synchronous.zig, which ends at `EPERM` = 9), so a client
/// reads one vocabulary whether the number came from the kernel or a provider.
/// Positive here, sent negated in `Status.status`, as the kernel spells it.
pub const ENOSYS: i32 = 10; // this protocol has no such operation
pub const EPROTO: i32 = 11; // malformed packet: shorter than the verb it names
pub const EBUSY: i32 = 12; // the thing asked for is held by someone still alive
/// Restated from the kernel's half of the numbering, because a provider refuses
/// too and userspace has no other place to read these from: `ENOENT` is "no such
/// name", `EPERM` "not permitted". The protocol registry answers an ungranted
/// bind with the second and a name a live provider already holds with `EBUSY`.
pub const ENOENT: i32 = 4;
pub const ENOSPC: i32 = 5;
pub const EPERM: i32 = 9;
// --- framing ----------------------------------------------------------------
/// The header of a received packet, or null when it is too short to have one.
pub fn headerOf(packet: []const u8) ?Header {
if (packet.len < prefix_size) return null;
return std.mem.bytesToValue(Header, packet[0..prefix_size]);
}
/// The status of a received reply, or null when it is too short to have one.
pub fn statusOf(packet: []const u8) ?Status {
if (packet.len < prefix_size) return null;
return std.mem.bytesToValue(Status, packet[0..prefix_size]);
}
/// Frame a bare `describe` request. Protocol-independent: the reserved verbs
/// are asked the same way of every provider.
pub fn encodeDescribe(buffer: []u8) ?[]u8 {
const header = Header{ .operation = operation_describe };
return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer);
}
/// A decoded `describe` reply: the fixed part, plus the name that follows it.
pub const Described = struct {
description: Description,
name: []const u8,
};
/// Decode a `describe` reply packet. Null if it failed, was truncated, or is
/// not a description at all.
pub fn decodeDescribe(packet: []const u8) ?Described {
const status = statusOf(packet) orelse return null;
if (status.status != 0) return null;
const body = packet[prefix_size..];
if (body.len < @sizeOf(Description)) return null;
const description = std.mem.bytesToValue(Description, body[0..@sizeOf(Description)]);
const name = body[@sizeOf(Description)..];
if (name.len < description.name_len) return null;
return .{ .description = description, .name = name[0..description.name_len] };
}
/// The single framing point: prefix, then the fixed part, then the variable
/// tail, contiguous in one buffer. Null when the packet would not fit — a
/// packet is never split, so not fitting is a failure, not a continuation.
fn frame(prefix: []const u8, fixed: []const u8, tail: []const u8, buffer: []u8) ?[]u8 {
const total = prefix.len + fixed.len + tail.len;
if (total > buffer.len) return null;
@memcpy(buffer[0..prefix.len], prefix);
@memcpy(buffer[prefix.len..][0..fixed.len], fixed);
@memcpy(buffer[prefix.len + fixed.len ..][0..tail.len], tail);
return buffer[0..total];
}
/// The bytes of a fixed part — empty for `void`, which is how an operation says
/// "nothing but the verb".
fn bytesOf(comptime T: type, value: *const T) []const u8 {
if (@sizeOf(T) == 0) return &.{};
return @as([*]const u8, @ptrCast(value))[0..@sizeOf(T)];
}
/// Read a fixed part out of a packet body. A zero-sized part always succeeds
/// (there is nothing to be short of); anything else needs its full width.
fn valueOf(comptime T: type, body: []const u8) ?T {
if (@sizeOf(T) == 0) return @as(T, undefined);
if (body.len < @sizeOf(T)) return null;
return std.mem.bytesToValue(T, body[0..@sizeOf(T)]);
}
// --- the specification ------------------------------------------------------
/// One verb of a protocol. `request` and `reply` describe the bytes *after* the
/// prefix; either may be `void`, meaning the verb (and its target) says it all.
pub const OperationSpecification = struct {
name: []const u8,
request: type = void,
reply: type = void,
};
/// One event a provider pushes to its subscribers. `payload` is the bytes after
/// the `Header`, and the whole packet must fit the push floor.
pub const EventSpecification = struct {
name: []const u8,
payload: type = void,
};
/// What `Define` is given: the contract, whole.
pub const Specification = struct {
/// The contract's name — the same word as its `/protocol/<name>` leaf and
/// its `library/protocol/` module.
name: []const u8,
version: u32,
operations: []const OperationSpecification = &.{},
events: []const EventSpecification = &.{},
};
const reserved_names = [_][]const u8{ "describe", "enumerate", "subscribe", "unsubscribe" };
fn isReservedName(comptime name: []const u8) bool {
for (reserved_names) |reserved| {
if (std.mem.eql(u8, reserved, name)) return true;
}
return false;
}
/// Refuse a protocol type that carries the prefix inside itself. The header is
/// folded into every packet, so a type that also holds one would send it twice
/// and re-invent per-protocol addressing — the mistake the envelope exists to
/// prevent.
fn rejectStacking(comptime protocol: []const u8, comptime verb: []const u8, comptime T: type) void {
switch (@typeInfo(T)) {
.@"struct" => |info| for (info.fields) |field| {
if (field.type == Header or field.type == Status) @compileError(std.fmt.comptimePrint(
"protocol '{s}', verb '{s}': the envelope prefix is folded, not stacked — " ++
"drop the {s} field '{s}' and use the packet's own Header.operation / Header.target",
.{ protocol, verb, @typeName(field.type), field.name },
));
},
else => {},
}
}
fn nullDefault(comptime T: type) *const anyopaque {
const empty: ?T = null;
return @ptrCast(&empty);
}
// --- Define -----------------------------------------------------------------
/// Build a protocol from its specification. Everything below happens at compile
/// time; the generated type is what both sides of the conversation import.
///
/// ```zig
/// pub const Protocol = envelope.Define(.{
/// .name = "display",
/// .version = 1,
/// .operations = &.{
/// .{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
/// .{ .name = "blit", .request = Blit, .reply = void },
/// },
/// .events = &.{
/// .{ .name = "layer_lost", .payload = LayerLost },
/// },
/// });
/// ```
///
/// Refused at compile time, each with the protocol, the verb, and the numbers
/// named in the message:
///
/// - a request or reply that does not fit `packet_maximum` once `prefix_size`
/// is added (`.request = extern struct { bytes: [241]u8 }` — 241 + 16 = 257);
/// - an event payload that does not fit `post_maximum` the same way
/// (`.payload = extern struct { bytes: [49]u8 }` — 49 + 16 = 65);
/// - a type that stacks the prefix instead of folding it (a `Header` field);
/// - a verb named after a reserved one, or named twice.
///
/// A variable tail is bounded at *run* time instead, by `encodeRequest` and its
/// siblings, because only the caller knows how long it is.
pub fn Define(comptime specification: Specification) type {
comptime {
if (specification.name.len == 0) @compileError("a protocol needs a name");
if (specification.name.len > name_maximum) @compileError(std.fmt.comptimePrint(
"protocol '{s}': the name is {d} bytes, and a describe reply carries at most {d}",
.{ specification.name, specification.name.len, name_maximum },
));
for (specification.operations, 0..) |operation, index| {
if (isReservedName(operation.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': '{s}' is a reserved universal verb — the envelope already answers it",
.{ specification.name, operation.name },
));
for (specification.operations[0..index]) |earlier| {
if (std.mem.eql(u8, earlier.name, operation.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': operation '{s}' is declared twice",
.{ specification.name, operation.name },
));
}
rejectStacking(specification.name, operation.name, operation.request);
rejectStacking(specification.name, operation.name, operation.reply);
if (!fitsPacket(operation.request)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', operation '{s}': the request is {d} bytes and the header {d}, " ++
"over the {d}-byte call floor — packets never fragment, so this has to shrink " ++
"or move its bulk to shared memory",
.{ specification.name, operation.name, @sizeOf(operation.request), prefix_size, packet_maximum },
));
if (!fitsPacket(operation.reply)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', operation '{s}': the reply is {d} bytes and the status header {d}, " ++
"over the {d}-byte call floor",
.{ specification.name, operation.name, @sizeOf(operation.reply), prefix_size, packet_maximum },
));
}
for (specification.events, 0..) |event, index| {
for (specification.events[0..index]) |earlier| {
if (std.mem.eql(u8, earlier.name, event.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': event '{s}' is declared twice",
.{ specification.name, event.name },
));
}
rejectStacking(specification.name, event.name, event.payload);
if (!fitsPost(event.payload)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', event '{s}': the payload is {d} bytes and the header {d}, " ++
"over the {d}-byte push floor — an event carries the header too, so it is the " ++
"payload that has to give",
.{ specification.name, event.name, @sizeOf(event.payload), prefix_size, post_maximum },
));
}
}
return struct {
pub const protocol_name: []const u8 = specification.name;
pub const version: u32 = specification.version;
/// What a provider sizes its receive and reply buffers to. A packet's
/// fixed part may be far smaller, but any caller may send up to the
/// floor and a short buffer truncates rather than refuses.
pub const message_maximum: usize = packet_maximum;
/// The widest packet this protocol's fixed parts can actually produce,
/// prefix included — a diagnostic, and what a test pins.
pub const request_maximum: usize = widest(specification.operations, .request);
pub const reply_maximum: usize = widest(specification.operations, .reply);
pub const event_maximum: usize = blk: {
var widest_event: usize = prefix_size;
for (specification.events) |event| widest_event = @max(widest_event, prefix_size + @sizeOf(event.payload));
break :blk widest_event;
};
/// This protocol's verbs, numbered from `first_protocol_operation` in
/// declaration order.
pub const Operation = numbered(specification.operations, "name");
/// This protocol's events, numbered from `first_protocol_operation` in
/// their **own** space. Events travel only provider → subscriber over
/// `ipc_send` and operations only client → provider over `ipc_call`, so
/// the direction already tells the two apart; separate spaces mean
/// appending an operation can never renumber a shipped event.
pub const Event = numbered(specification.events, "name");
/// The bytes after the `Header` on a request for `operation`.
pub fn RequestOf(comptime operation: Operation) type {
return specification.operations[indexOf(@intFromEnum(operation))].request;
}
/// The bytes after the `Status` on the reply to `operation`.
pub fn ReplyOf(comptime operation: Operation) type {
return specification.operations[indexOf(@intFromEnum(operation))].reply;
}
/// The bytes after the `Header` on an `event` packet.
pub fn PayloadOf(comptime event: Event) type {
return specification.events[indexOf(@intFromEnum(event))].payload;
}
// --- client side ----------------------------------------------------
/// Frame `[Header][request][tail]`. `tail` is the variable part (a path,
/// write bytes); pass `&.{}` when the verb has none. Null if the packet
/// would exceed the buffer or the call floor.
pub fn encodeRequest(
comptime operation: Operation,
target: u64,
request: RequestOf(operation),
tail: []const u8,
buffer: []u8,
) ?[]u8 {
const header = Header{ .operation = @intFromEnum(operation), .target = target };
const packet = frame(std.mem.asBytes(&header), bytesOf(RequestOf(operation), &request), tail, buffer) orelse return null;
return if (packet.len > packet_maximum) null else packet;
}
/// Frame `[Status][reply][tail]` — the provider's answer, for a provider
/// that composes its own reply rather than using `Provider.dispatch`.
pub fn encodeReply(
comptime operation: Operation,
status: i32,
reply: ReplyOf(operation),
tail: []const u8,
buffer: []u8,
) ?[]u8 {
const fixed = bytesOf(ReplyOf(operation), &reply);
const head = Status{ .status = status, .len = @intCast(fixed.len + tail.len) };
const packet = frame(std.mem.asBytes(&head), fixed, tail, buffer) orelse return null;
return if (packet.len > packet_maximum) null else packet;
}
/// Frame `[Header][payload]` for an asynchronous push. Null if it would
/// exceed the buffer or the push floor — an event that does not fit is
/// dropped at the source, never split.
pub fn encodeEvent(
comptime event: Event,
target: u64,
payload: PayloadOf(event),
buffer: []u8,
) ?[]u8 {
const header = Header{ .operation = @intFromEnum(event), .target = target };
const packet = frame(std.mem.asBytes(&header), bytesOf(PayloadOf(event), &payload), &.{}, buffer) orelse return null;
return if (packet.len > post_maximum) null else packet;
}
/// Which of this protocol's verbs a packet names — null for a reserved
/// verb, or for a number this protocol does not define.
pub fn operationOf(packet: []const u8) ?Operation {
const header = headerOf(packet) orelse return null;
const index = header.operation -% first_protocol_operation;
if (header.operation < first_protocol_operation or index >= specification.operations.len) return null;
return @enumFromInt(header.operation);
}
/// Which of this protocol's events a pushed packet carries.
pub fn eventOf(packet: []const u8) ?Event {
const header = headerOf(packet) orelse return null;
const index = header.operation -% first_protocol_operation;
if (header.operation < first_protocol_operation or index >= specification.events.len) return null;
return @enumFromInt(header.operation);
}
/// The fixed request part of a packet already known to name `operation`.
pub fn decodeRequest(comptime operation: Operation, packet: []const u8) ?RequestOf(operation) {
if (packet.len < prefix_size) return null;
return valueOf(RequestOf(operation), packet[prefix_size..]);
}
/// The bytes after the fixed request part — empty when there are none.
pub fn requestTail(comptime operation: Operation, packet: []const u8) []const u8 {
const start = prefix_size + @sizeOf(RequestOf(operation));
return if (packet.len <= start) &.{} else packet[start..];
}
/// The fixed reply part of a reply packet. Null on a short packet; the
/// caller checks `statusOf(packet).status` for the provider's verdict.
pub fn decodeReply(comptime operation: Operation, packet: []const u8) ?ReplyOf(operation) {
if (packet.len < prefix_size) return null;
return valueOf(ReplyOf(operation), packet[prefix_size..]);
}
/// The bytes after the fixed reply part, clipped to what `Status.len`
/// says actually arrived.
pub fn replyTail(comptime operation: Operation, packet: []const u8) []const u8 {
const status = statusOf(packet) orelse return &.{};
const start = prefix_size + @sizeOf(ReplyOf(operation));
const end = @min(packet.len, prefix_size + @as(usize, status.len));
return if (end <= start) &.{} else packet[start..end];
}
/// The payload of a pushed packet already known to carry `event`.
pub fn decodeEvent(comptime event: Event, packet: []const u8) ?PayloadOf(event) {
if (packet.len < prefix_size) return null;
return valueOf(PayloadOf(event), packet[prefix_size..]);
}
// --- provider side --------------------------------------------------
/// This protocol's dispatch table, bound to the provider's own state
/// type. `describe` is answered here, from the specification; every verb
/// this provider left null answers `-ENOSYS`, which is what makes the
/// reserved verbs mean the same thing at every provider in the system.
///
/// ```zig
/// const Serve = Protocol.Provider(*Server);
/// const handlers = Serve.Handlers{ .blit = onBlit, .configure_layer = onConfigureLayer };
/// const reply_len = Serve.dispatch(server, handlers, message, sender, capability, reply);
/// ```
///
/// A handler returns the number of `answer.tail()` bytes it wrote, or a
/// negative errno.
pub fn Provider(comptime Context: type) type {
return struct {
/// A reserved verb a provider chooses to implement itself.
/// `enumerate` writes its targets into the tail; `subscribe`
/// takes the subscriber's endpoint from `invocation.capability`.
pub const ReservedHandler = *const fn (Context, Invocation(void), Answer(void)) isize;
/// One optional handler per verb, named exactly as the verb,
/// plus the reserved verbs the envelope cannot answer alone.
pub const Handlers = handlerTable(Context);
/// Answer one received packet: writes `[Status][reply][tail]`
/// into `reply` and returns its length. Zero means the reply
/// buffer could not even hold a status, so nothing was written.
pub fn dispatch(
context: Context,
handlers: Handlers,
packet: []const u8,
sender: u32,
capability: ?usize,
reply: []u8,
) usize {
if (reply.len < prefix_size) return 0;
const header = headerOf(packet) orelse return refuse(reply, -EPROTO);
const body = packet[prefix_size..];
if (header.operation == operation_describe) return describeInto(reply);
inline for (specification.operations, 0..) |operation, index| {
if (header.operation == first_protocol_operation + index) {
return invoke(
Context,
operation.request,
operation.reply,
@field(handlers, operation.name),
context,
header.target,
body,
sender,
capability,
reply,
);
}
}
const reserved: ?ReservedHandler = switch (header.operation) {
operation_enumerate => handlers.enumerate,
operation_subscribe => handlers.subscribe,
operation_unsubscribe => handlers.unsubscribe,
else => null,
};
return invoke(Context, void, void, reserved, context, header.target, body, sender, capability, reply);
}
};
}
// Shared by the protocol verbs and the reserved ones: decode, hand the
// handler a typed invocation, stamp the status. One place, so a reserved
// verb and a protocol verb behave identically.
fn invoke(
comptime Context: type,
comptime RequestType: type,
comptime ReplyType: type,
handler: ?*const fn (Context, Invocation(RequestType), Answer(ReplyType)) isize,
context: Context,
target: u64,
body: []const u8,
sender: u32,
capability: ?usize,
reply: []u8,
) usize {
const call = handler orelse return refuse(reply, -ENOSYS);
const request = valueOf(RequestType, body) orelse return refuse(reply, -EPROTO);
if (reply.len < prefix_size + @sizeOf(ReplyType)) return refuse(reply, -EPROTO);
const produced = call(context, .{
.target = target,
.request = request,
.tail = body[@min(@sizeOf(RequestType), body.len)..],
.sender = sender,
.capability = capability,
}, .{ .buffer = reply[prefix_size..] });
if (produced < 0) return refuse(reply, @intCast(produced));
return succeed(reply, @sizeOf(ReplyType) + @as(usize, @intCast(produced)));
}
fn describeInto(reply: []u8) usize {
const description = Description{
.version = specification.version,
.operation_count = specification.operations.len,
.event_count = specification.events.len,
.name_len = specification.name.len,
};
const total = @sizeOf(Description) + specification.name.len;
if (reply.len < prefix_size + total) return refuse(reply, -EPROTO);
@memcpy(reply[prefix_size..][0..@sizeOf(Description)], std.mem.asBytes(&description));
@memcpy(reply[prefix_size + @sizeOf(Description) ..][0..specification.name.len], specification.name);
return succeed(reply, total);
}
// "describe" is answered by the envelope, so it is the one reserved verb
// with no slot in the table.
const implementable_reserved = [_][]const u8{ "enumerate", "subscribe", "unsubscribe" };
fn handlerTable(comptime Context: type) type {
const count = specification.operations.len + implementable_reserved.len;
var names: [count][]const u8 = undefined;
var types: [count]type = undefined;
var attributes: [count]std.builtin.Type.StructField.Attributes = undefined;
for (specification.operations, 0..) |operation, index| {
const Handler = *const fn (Context, Invocation(operation.request), Answer(operation.reply)) isize;
names[index] = operation.name;
types[index] = ?Handler;
attributes[index] = .{ .default_value_ptr = nullDefault(Handler) };
}
const Reserved = *const fn (Context, Invocation(void), Answer(void)) isize;
for (implementable_reserved, 0..) |name, offset| {
const slot = specification.operations.len + offset;
names[slot] = name;
types[slot] = ?Reserved;
attributes[slot] = .{ .default_value_ptr = nullDefault(Reserved) };
}
const frozen_names = names;
const frozen_types = types;
const frozen_attributes = attributes;
return @Struct(.auto, null, &frozen_names, &frozen_types, &frozen_attributes);
}
};
}
// --- the provider's view of one packet --------------------------------------
/// What a provider's handler is given.
pub fn Invocation(comptime RequestType: type) type {
return struct {
/// Object addressing within this provider — the packet's `Header.target`.
target: u64,
/// The fixed request part, already decoded.
request: RequestType,
/// The bytes after it: a path, write data, a name.
tail: []const u8,
/// The kernel-stamped badge of the caller. The only source identity
/// there is — no protocol defines a sender field — so per-client state
/// is keyed on this.
sender: u32,
/// A capability the call carried: a subscriber's endpoint, a DMA
/// region. Only the synchronous call path can move one.
capability: ?usize,
};
}
/// Where a provider's handler writes its answer. The `Status` in front of it is
/// the dispatcher's to stamp — a handler never writes its own.
pub fn Answer(comptime ReplyType: type) type {
return struct {
buffer: []u8,
const fixed_size = @sizeOf(ReplyType);
/// Write the fixed reply part. A `void` reply writes nothing.
pub fn set(self: @This(), reply: ReplyType) void {
if (fixed_size == 0) return;
@memcpy(self.buffer[0..fixed_size], bytesOf(ReplyType, &reply));
}
/// Room for the variable tail; the handler returns how much of it it used.
pub fn tail(self: @This()) []u8 {
return self.buffer[fixed_size..];
}
};
}
fn refuse(reply: []u8, status: i32) usize {
const head = Status{ .status = status, .len = 0 };
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
return prefix_size;
}
fn succeed(reply: []u8, len: usize) usize {
const head = Status{ .status = 0, .len = @intCast(len) };
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
return prefix_size + len;
}
/// The widest `[prefix][fixed]` over a set of operations, on one side.
fn widest(comptime operations: []const OperationSpecification, comptime side: enum { request, reply }) usize {
var found: usize = prefix_size;
for (operations) |operation| {
const size = switch (side) {
.request => @sizeOf(operation.request),
.reply => @sizeOf(operation.reply),
};
found = @max(found, prefix_size + size);
}
return found;
}
/// An enum over `specifications`, tagged by their `name` field, numbered from
/// `first_protocol_operation` in declaration order.
fn numbered(comptime specifications: anytype, comptime field: []const u8) type {
var names: [specifications.len][]const u8 = undefined;
var values: [specifications.len]u32 = undefined;
for (specifications, 0..) |specification, index| {
names[index] = @field(specification, field);
values[index] = first_protocol_operation + index;
}
const frozen_names = names;
const frozen_values = values;
return @Enum(u32, .exhaustive, &frozen_names, &frozen_values);
}
/// A verb's position in its declaration list, from its wire number.
fn indexOf(comptime operation: u32) usize {
return operation - first_protocol_operation;
}
// --- tests ------------------------------------------------------------------
const testing = std.testing;
const Produce = extern struct { count: u32, flags: u32 = 0 };
const Produced = extern struct { total: u64 };
const Changed = extern struct { kind: u32, value: u32 };
const Sample = Define(.{
.name = "sample",
.version = 3,
.operations = &.{
.{ .name = "produce", .request = Produce, .reply = Produced },
.{ .name = "reset" },
},
.events = &.{
.{ .name = "changed", .payload = Changed },
},
});
test "the prefix is 16 bytes in both directions" {
try testing.expectEqual(@as(usize, 16), @sizeOf(Header));
try testing.expectEqual(@as(usize, 16), @sizeOf(Status));
try testing.expectEqual(@as(usize, 16), prefix_size);
try testing.expectEqual(@as(usize, 256), packet_maximum);
try testing.expectEqual(@as(usize, 64), post_maximum);
}
test "verb numbering skips the reserved range" {
try testing.expectEqual(@as(u32, 16), first_protocol_operation);
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Operation.produce));
try testing.expectEqual(@as(u32, 17), @intFromEnum(Sample.Operation.reset));
// Events are numbered in their own space, so appending an operation can
// never renumber a shipped event.
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Event.changed));
for ([_]u32{ operation_describe, operation_enumerate, operation_subscribe, operation_unsubscribe }) |reserved| {
try testing.expect(reserved < first_protocol_operation);
}
}
test "request round trip, header folded and tail carried" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.produce, 42, .{ .count = 7 }, "tail bytes", &buffer).?;
try testing.expectEqual(prefix_size + @sizeOf(Produce) + "tail bytes".len, packet.len);
const header = headerOf(packet).?;
try testing.expectEqual(@as(u32, 16), header.operation);
try testing.expectEqual(@as(u64, 42), header.target);
try testing.expectEqual(Sample.Operation.produce, Sample.operationOf(packet).?);
const request = Sample.decodeRequest(.produce, packet).?;
try testing.expectEqual(@as(u32, 7), request.count);
try testing.expectEqualStrings("tail bytes", Sample.requestTail(.produce, packet));
}
test "reply round trip" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeReply(.produce, 0, .{ .total = 99 }, "more", &buffer).?;
const status = statusOf(packet).?;
try testing.expectEqual(@as(i32, 0), status.status);
try testing.expectEqual(@as(u32, @sizeOf(Produced) + "more".len), status.len);
try testing.expectEqual(@as(u64, 99), Sample.decodeReply(.produce, packet).?.total);
try testing.expectEqualStrings("more", Sample.replyTail(.produce, packet));
}
test "a void request and reply carry nothing but the verb" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.reset, 0, {}, &.{}, &buffer).?;
try testing.expectEqual(prefix_size, packet.len);
try testing.expectEqual(Sample.Operation.reset, Sample.operationOf(packet).?);
try testing.expectEqual(@as(usize, 0), Sample.requestTail(.reset, packet).len);
}
test "event round trip within the push floor" {
var buffer: [post_maximum]u8 = undefined;
const packet = Sample.encodeEvent(.changed, 0, .{ .kind = 1, .value = 2 }, &buffer).?;
try testing.expectEqual(prefix_size + @sizeOf(Changed), packet.len);
try testing.expect(packet.len <= post_maximum);
try testing.expectEqual(Sample.Event.changed, Sample.eventOf(packet).?);
try testing.expectEqual(@as(u32, 2), Sample.decodeEvent(.changed, packet).?.value);
}
// A provider over a trivial context, to drive the generated dispatch table.
const Counter = struct {
total: u64 = 0,
fn onProduce(self: *Counter, invocation: Invocation(Produce), answer: Answer(Produced)) isize {
self.total += invocation.request.count;
answer.set(.{ .total = self.total });
const note = "counted";
@memcpy(answer.tail()[0..note.len], note);
return note.len;
}
};
const CounterProvider = Sample.Provider(*Counter);
test "dispatch reaches a handler and stamps the status" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var request: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.produce, 0, .{ .count = 5 }, &.{}, &request).?;
var reply: [packet_maximum]u8 = undefined;
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
const answered = reply[0..len];
try testing.expectEqual(@as(i32, 0), statusOf(answered).?.status);
try testing.expectEqual(@as(u64, 5), Sample.decodeReply(.produce, answered).?.total);
try testing.expectEqualStrings("counted", Sample.replyTail(.produce, answered));
}
test "describe is answered by the envelope, not the provider" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var request: [packet_maximum]u8 = undefined;
const packet = encodeDescribe(&request).?;
var reply: [packet_maximum]u8 = undefined;
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
const described = decodeDescribe(reply[0..len]).?;
try testing.expectEqualStrings("sample", described.name);
try testing.expectEqual(@as(u32, 3), described.description.version);
try testing.expectEqual(@as(u32, 2), described.description.operation_count);
try testing.expectEqual(@as(u32, 1), described.description.event_count);
}
test "an unimplemented or unknown verb answers -ENOSYS" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var reply: [packet_maximum]u8 = undefined;
// A verb this protocol declares but this provider left null.
var request: [packet_maximum]u8 = undefined;
const declared = Sample.encodeRequest(.reset, 0, {}, &.{}, &request).?;
var len = CounterProvider.dispatch(&counter, handlers, declared, 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
// A number no verb of this protocol wears.
const stranger = Header{ .operation = first_protocol_operation + 900 };
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&stranger), 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
// A reserved verb the provider does not implement answers the same way.
const enumerate = Header{ .operation = operation_enumerate };
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&enumerate), 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
}
test "a truncated packet answers -EPROTO" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var reply: [packet_maximum]u8 = undefined;
// Names `produce`, but stops before the request it promises.
const header = Header{ .operation = @intFromEnum(Sample.Operation.produce) };
const len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&header), 3, null, &reply);
try testing.expectEqual(@as(i32, -EPROTO), statusOf(reply[0..len]).?.status);
}
// The size rule, exercised directly. `Define` turns exactly these predicates
// into compile errors, which a test cannot catch — so the predicate is what the
// test pins, and the boundary protocol below proves the compile-time half from
// the other side. The negative example, spelled out: giving `Define` an
// operation with `.request = extern struct { bytes: [241]u8 }`, or an event with
// `.payload = extern struct { bytes: [49]u8 }`, fails to compile with the
// protocol, the verb, and the two numbers named in the message.
test "a subscribe carries its interest mask in the reserved verb's tail" {
var buffer: [packet_maximum]u8 = undefined;
const packet = encodeSubscribe(0b101, &buffer).?;
try testing.expectEqual(operation_subscribe, headerOf(packet).?.operation);
try testing.expectEqual(@as(u32, 0b101), decodeSubscribe(packet[prefix_size..]).interest);
// No body at all — and a body too short to be one — read as "every event",
// which is what a subscriber that named nothing wants.
try testing.expectEqual(@as(u32, 0), decodeSubscribe(&.{}).interest);
try testing.expectEqual(@as(u32, 0), decodeSubscribe(&.{ 1, 2 }).interest);
const bare = encodeUnsubscribe(&buffer).?;
try testing.expectEqual(operation_unsubscribe, headerOf(bare).?.operation);
try testing.expectEqual(prefix_size, bare.len);
}
test "the floor counts the header once, and the boundary is exact" {
try testing.expect(fitsPacket(extern struct { bytes: [240]u8 }));
try testing.expect(!fitsPacket(extern struct { bytes: [241]u8 }));
try testing.expect(fitsPost(extern struct { bytes: [48]u8 }));
try testing.expect(!fitsPost(extern struct { bytes: [49]u8 }));
try testing.expect(fitsPacket(void));
try testing.expect(fitsPost(void));
}
const WidestRequest = extern struct { bytes: [packet_maximum - prefix_size]u8 };
const WidestEvent = extern struct { bytes: [post_maximum - prefix_size]u8 };
// A protocol sitting exactly on both floors. That this compiles at all is the
// positive half of the compile-time check.
const Boundary = Define(.{
.name = "boundary",
.version = 1,
.operations = &.{.{ .name = "fill", .request = WidestRequest, .reply = WidestRequest }},
.events = &.{.{ .name = "filled", .payload = WidestEvent }},
});
test "a protocol may sit exactly on the floor" {
try testing.expectEqual(packet_maximum, Boundary.request_maximum);
try testing.expectEqual(packet_maximum, Boundary.reply_maximum);
try testing.expectEqual(post_maximum, Boundary.event_maximum);
var buffer: [packet_maximum]u8 = undefined;
const packet = Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0xAB) }, &.{}, &buffer).?;
try testing.expectEqual(packet_maximum, packet.len);
try testing.expectEqual(@as(u8, 0xAB), Boundary.decodeRequest(.fill, packet).?.bytes[239]);
// One byte of tail past the floor is refused at run time, not truncated.
try testing.expect(Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0) }, "x", &buffer) == null);
var post: [post_maximum]u8 = undefined;
const event = Boundary.encodeEvent(.filled, 0, .{ .bytes = @splat(1) }, &post).?;
try testing.expectEqual(post_maximum, event.len);
}
test "a protocol's own sizes are reported prefix-included" {
try testing.expectEqual(prefix_size + @sizeOf(Produce), Sample.request_maximum);
try testing.expectEqual(prefix_size + @sizeOf(Produced), Sample.reply_maximum);
try testing.expectEqual(prefix_size + @sizeOf(Changed), Sample.event_maximum);
try testing.expectEqual(packet_maximum, Sample.message_maximum);
try testing.expectEqualStrings("sample", Sample.protocol_name);
}
+106 -45
View File
@@ -4,25 +4,30 @@
//! **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 (`envelope.Subscription`), because a reserved verb carries no typed
//! request; what this protocol supplies is the *meaning* of its bits — the device classes.
//! - **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 +247,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 +293,88 @@ 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
};
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 },
},
});
/// 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 },
};
/// 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. The mask itself is the envelope's `Subscription` — the interest
/// a reserved subscribe carries is universal, and the *meaning* of its bits (here: the
/// device classes above) is what each protocol supplies. Kept as a named helper because
/// `device_mask` is what an input caller calls it.
pub fn encodeSubscribe(device_mask: u32, buffer: []u8) ?[]u8 {
return envelope.encodeSubscribe(device_mask, buffer);
}
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);
// The provider side of this is the service harness's, which reads the same
// interest mask out of the tail for every protocol.
try std.testing.expectEqual(device_mouse, envelope.decodeSubscribe(packet[envelope.prefix_size..]).interest);
// A caller that sent nothing at all reads as the every-class mask.
try std.testing.expectEqual(@as(u32, 0), envelope.decodeSubscribe(&.{}).interest);
}
+96 -60
View File
@@ -1,68 +1,104 @@
//! The power protocol (docs/power.md): system power's domain-named surface,
//! registered under `ServiceId.power`. On x86 the acpi service serves it; on
//! ARM a PSCI/mailbox service will register the same id — subscribers never
//! learn which firmware they are on (docs/discovery.md — firmware neutrality).
//! The vfs-protocol pattern: extern-struct messages, a version, reserved fields.
//! The power protocol (docs/os-development/power.md): system power's
//! domain-named surface, bound at `/protocol/power`. On x86 the acpi service
//! provides it; on ARM a PSCI/mailbox service will bind the same name —
//! subscribers never learn which firmware they are on (docs/discovery.md —
//! firmware neutrality), which is the whole point of naming the contract rather
//! than the provider (docs/os-development/protocol-namespace.md).
//!
//! Defined through the envelope, so every packet begins with the folded
//! `Header`. Three shapes ride the channel, and the envelope names all three:
//!
//! - **subscribe** is the *reserved* verb, not one of this protocol's own: a
//! synchronous call whose attached capability is the subscriber's endpoint is
//! exactly what `envelope.operation_subscribe` means everywhere.
//! - **shutdown** is this protocol's one verb — the only operation that *does*
//! something irreversible, and the reason the provider gates it by badge.
//! - **the events** are pushes: the service `ipc_send`s each one to every
//! subscriber, no reply owed, so a slow or dead subscriber can never wedge the
//! source. **The kind is the packet's operation** — one declared event per
//! named kind, exactly as the input protocol delivers one per device class —
//! so a subscriber reads *what happened* out of the header instead of a tag
//! inside the payload. That is what the old `EventMessage`'s two leading bytes
//! (an operation byte saying "this is an event", then the kind) fold into.
//!
//! `Header.target` is unused (0) in both directions: the provider is the only
//! object either side addresses. And no packet carries a version any more — the
//! reserved `describe` verb is the version handshake, asked once at connect time
//! rather than re-carried out of every packet's budget.
/// The protocol version a client states nowhere yet — reserved for the day a
/// handshake needs it; requests carry it so a mismatch can be refused loudly.
pub const version: u16 = 1;
const std = @import("std");
const envelope = @import("envelope");
pub const Operation = enum(u8) {
/// Subscribe to power events: the subscriber's endpoint rides as the
/// call's capability (the input/device-manager pattern); events arrive on
/// it as buffered messages carrying an `EventMessage`.
subscribe = 1,
/// Orderly shutdown's last step: enter S5. Accepted only from PID 1
/// (init) — the process that has already run the stop sequence over
/// everything else.
shutdown = 2,
/// The published event payload (never sent *to* the service).
event = 3,
};
/// What happened. The vocabulary is hardware-neutral: a lid is a lid whether
/// ACPI or a PSCI mailbox reported it.
pub const Event = enum(u8) {
power_button = 1,
lid = 2,
ac = 3,
battery = 4,
/// A device notification that maps to none of the named events — the
/// `code` and `hid` fields say which device and what code.
notify = 5,
};
pub const Subscribe = extern struct {
operation: u8 = @intFromEnum(Operation.subscribe),
reserved0: u8 = 0,
version: u16 = version,
reserved1: u32 = 0,
};
pub const Shutdown = extern struct {
operation: u8 = @intFromEnum(Operation.shutdown),
reserved0: u8 = 0,
version: u16 = version,
reserved1: u32 = 0,
};
/// A published event, as the buffered-message payload subscribers receive.
pub const EventMessage = extern struct {
operation: u8 = @intFromEnum(Operation.event),
/// An Event value.
event: u8,
reserved0: u16 = 0,
/// The device notification code (Notify's second argument), or 0.
/// What a published event carries beyond its kind. The kind is the packet's
/// operation, so nothing here repeats it; `power_button`, `lid`, `ac` and
/// `battery` leave both fields zero and are fully described by the verb alone.
pub const Notice = extern struct {
/// The device notification code (ACPI `Notify`'s second argument), or 0.
code: u32 = 0,
/// The notifying device's hardware id (EISA-decoded), or all zero.
hid: [8]u8 = .{0} ** 8,
};
pub const Reply = extern struct {
status: i32,
reserved: u32 = 0,
};
pub const Protocol = envelope.Define(.{
.name = "power",
.version = 1,
.operations = &.{
// Orderly shutdown's last step: enter S5. Honored only from a
// subscriber — init, the process that has already run the stop sequence
// over everything else (docs/os-development/power.md, "authority, not
// information"). Nothing to say and nothing to answer, so the verb and
// the reply's `Status` are the whole exchange.
.{ .name = "shutdown" },
},
.events = &.{
// The vocabulary is hardware-neutral: a lid is a lid whether ACPI or a
// PSCI mailbox reported it. One event per kind, each carrying the same
// `Notice`, because what differs between them is which thing happened —
// and that is the header's job now.
.{ .name = "power_button", .payload = Notice },
.{ .name = "lid", .payload = Notice },
.{ .name = "ac", .payload = Notice },
.{ .name = "battery", .payload = Notice },
// A device notification that maps to none of the named events — the
// `code` and `hid` say which device and what happened.
.{ .name = "notify", .payload = Notice },
},
});
/// Upper bound on any message in this protocol — sizes endpoint buffers.
pub const message_maximum = 64;
pub const Operation = Protocol.Operation;
/// What happened. The event *is* the kind: this is the generated event
/// enumeration, re-exported under the name this protocol has always called its
/// vocabulary, with the same members it has always had.
pub const Event = Protocol.Event;
/// What a provider and a subscriber size their buffers to. This module used to
/// declare 64 — the *push* floor — which was simply wrong for a protocol whose
/// requests ride `ipc_call`: a provider sizing its receive buffer to 64 refuses
/// any caller that sends up to the floor it is entitled to.
pub const message_maximum: usize = Protocol.message_maximum;
test "the kind is the verb, and an event fits the push floor" {
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.shutdown));
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.power_button));
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.lid));
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Event.ac));
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Event.battery));
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Event.notify));
// subscribe is the RESERVED verb, below the protocol range entirely.
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
try std.testing.expectEqual(envelope.prefix_size + @sizeOf(Notice), Protocol.event_maximum);
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
// The call floor, not the push floor: `shutdown` is a synchronous call.
try std.testing.expectEqual(envelope.packet_maximum, message_maximum);
}
test "a pushed event names its kind in the header" {
var buffer: [envelope.post_maximum]u8 = undefined;
const packet = Protocol.encodeEvent(.power_button, 0, .{}, &buffer).?;
try std.testing.expectEqual(Event.power_button, Protocol.eventOf(packet).?);
const notified = Protocol.encodeEvent(.notify, 0, .{ .code = 0x80, .hid = "PNP0C0A\x00".* }, &buffer).?;
try std.testing.expectEqual(Event.notify, Protocol.eventOf(notified).?);
try std.testing.expectEqual(@as(u32, 0x80), Protocol.decodeEvent(.notify, notified).?.code);
}
+39 -33
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;
@@ -1,57 +1,66 @@
//! The USB transfer protocol: what a USB class driver (a keyboard, mouse, or
//! mass-storage driver) says to the xHCI bus driver over its well-known
//! `.usb_bus` endpoint to drive its device. The class driver owns no hardware —
//! it reaches its device entirely through these messages, the way a PS/2 keyboard
//! driver reaches the 8042 through the ps2-bus. Extern-struct messages tagged by
//! `Operation`, the vfs-protocol / device-manager-protocol pattern.
//! mass-storage driver) says to the xHCI bus driver over `/protocol/usb-transfer`
//! to drive its device. The class driver owns no hardware — it reaches its device
//! entirely through these packets, the way a PS/2 keyboard driver reaches the
//! 8042 through the ps2-bus.
//!
//! Defined through the envelope (docs/os-development/protocol-namespace.md), so
//! every packet begins with the folded `Header`. **`Header.target` is the device
//! token** — the per-open handle the bus driver hands back, which every request
//! but `open` addressed through a `device_token` field of its own before the
//! rebase. `open` itself addresses the *assigned device id*, because that is what
//! the caller has before there is a token.
//!
//! The shape:
//! - **open** (a capability-passing `ipc.callCap`): the class driver hands over
//! its own endpoint (for asynchronous interrupt reports) and its assigned
//! device id, and receives a `device_token` plus its interface's endpoints.
//! - **control / bulk** (synchronous `ipc.call`): one transfer, answered when
//! it completes. Control data travels inline (descriptors, HID/MSC class
//! requests are all small); bulk data travels by **physical address** — the
//! class driver's own `dma_alloc`'d buffer — so a 512-byte sector never has
//! to cross the 256-byte IPC boundary.
//! - **open** (a capability-passing call): the class driver hands over its own
//! endpoint (for asynchronous interrupt reports); the target is its assigned
//! device id, and the reply carries a `device_token` plus its interface's
//! endpoints.
//! - **control / bulk** (synchronous calls): one transfer, answered when it
//! completes. Control data travels **in the packet's tail** in both
//! directions (descriptors, HID/MSC class requests are all small), so the
//! fixed parts stay tiny and `Status.len` is the transferred length — the
//! envelope's own field for "how many bytes follow", which is precisely what
//! the old `actual_length` said. Bulk data travels by **physical address** —
//! the class driver's own `dma_alloc`'d buffer — so a 512-byte sector never
//! has to cross the packet floor.
//! - **interrupt_subscribe** (synchronous): arm periodic IN polling of an
//! interrupt endpoint; each report the device produces is then pushed to the
//! class driver's endpoint as an asynchronous `InterruptReport` (`ipc.send`),
//! exactly how the input service delivers events.
//! class driver's endpoint as an asynchronous `interrupt_report` event.
//! It stays one of **this protocol's own verbs**, not the reserved
//! `subscribe`: the reserved verb means "push me this provider's events" and
//! carries the subscriber's endpoint, while this names one endpoint address
//! on one device and a poll length, and the endpoint it pushes to was handed
//! over at `open`. Same word, different contract.
//! - **dma_attach**: a class driver hands the controller a DMA-region
//! capability (riding the call's cap slot) so the controller binds that
//! buffer into its IOMMU domain and may then DMA to the physical addresses
//! inside it. Needed once per buffer the class driver will name in a `bulk`
//! transfer (its own, or one forwarded to it).
//!
//! Single controller assumption: one `.usb_bus` singleton serves QEMU's one xHCI.
//! A multi-controller machine would need a per-controller endpoint (the device
//! manager handing each class driver the right one); noted, not built.
//! Single controller assumption: one provider serves QEMU's one xHCI. A
//! multi-controller machine would need the controller in the target (or the
//! spawner wiring each class driver its own channel); noted, not built.
/// Fits one synchronous IPC message (kernel MESSAGE_MAXIMUM).
pub const message_maximum: usize = 256;
const std = @import("std");
const envelope = @import("envelope");
/// The largest inline control-transfer payload. Sized so a whole message
/// (header + data) stays under `message_maximum`: descriptors and HID/MSC class
/// requests are all far smaller.
pub const max_inline_data: usize = 200;
/// The largest control-transfer data stage. It rides the packet's tail, so the
/// bound is the call floor less the header and the fixed request part — derived
/// rather than declared, which is what keeps it honest when a field moves.
pub const max_inline_data: usize = envelope.packet_maximum - envelope.prefix_size - @sizeOf(Control);
/// The largest interrupt report pushed asynchronously. Sized so `InterruptReport`
/// fits an `ipc_send` payload slot (POST_MAXIMUM = 64): boot keyboard reports are
/// 8 bytes, boot mouse reports 3–4.
pub const max_report_data: usize = 48;
/// The largest interrupt report pushed asynchronously. An event packet is the
/// header plus the payload within 64 bytes, so this is what is left after the
/// report's own four bytes of framing: boot keyboard reports are 8 bytes, boot
/// mouse reports 3–4, and the whole HID boot vocabulary fits many times over.
/// A device that produces more has its report truncated, never split.
pub const max_report_data: usize = 40;
/// Endpoints per interface reported back in an open reply (a boot HID interface
/// has one interrupt endpoint, a mass-storage interface two bulk endpoints).
pub const max_reported_endpoints: usize = 4;
pub const Operation = enum(u32) {
open = 0,
control = 1,
interrupt_subscribe = 2,
bulk = 3,
/// dma_attach: a class driver hands the controller a DMA-region capability (riding
/// the call's cap slot) so the controller binds that buffer into its IOMMU domain
/// and may then DMA to the physical addresses inside it. Needed once per buffer the
/// class driver will name in a `bulk` transfer (its own, or one forwarded to it).
dma_attach = 4,
};
/// The endpoint facts a class driver needs, lifted from the endpoint descriptor
/// the bus driver already parsed during enumeration.
pub const Endpoint = extern struct {
@@ -64,114 +73,146 @@ pub const Endpoint = extern struct {
reserved: [3]u8 = .{ 0, 0, 0 },
};
/// open: the class driver's receive endpoint rides as the call's capability, and
/// `device_id` is the interface's assigned id (its argv[1]).
pub const OpenRequest = extern struct {
operation: u32 = @intFromEnum(Operation.open),
reserved: u32 = 0,
device_id: u64,
};
// --- the per-operation request and reply parts ------------------------------
//
// Each names the bytes AFTER the prefix. Nothing here carries an operation or a
// device token: those are the packet header's, folded in once. No reply carries
// a status either — that is the `Status` every reply begins with.
/// The answer to open: a token scoping every later request to this device, the
/// interface's class triple (a sanity check), and its endpoints.
pub const OpenReply = extern struct {
status: i32,
endpoint_count: u32,
/// The answer to `open`: the token every later packet puts in `Header.target`,
/// the interface's class triple (a sanity check), and its endpoints.
pub const Opened = extern struct {
device_token: u64,
endpoint_count: u32,
interface_class: u8,
interface_subclass: u8,
interface_protocol: u8,
interface_number: u8,
reserved2: u32 = 0,
endpoints: [max_reported_endpoints]Endpoint = [_]Endpoint{.{ .address = 0, .transfer_type = 0, .max_packet_size = 0, .interval = 0 }} ** max_reported_endpoints,
};
/// control: one EP0 control transfer. `setup` is a bit-cast `usb_abi.Request`.
/// For an OUT transfer `data[0..data_length]` is sent; for an IN transfer the
/// reply carries up to `data_length` bytes back.
pub const ControlRequest = extern struct {
operation: u32 = @intFromEnum(Operation.control),
reserved: u32 = 0,
device_token: u64,
/// `control`: one EP0 control transfer on `Header.target`. `setup` is a bit-cast
/// `usb_abi.Request`. For an OUT transfer the data stage is the request's tail;
/// for an IN transfer it comes back as the reply's tail, and `Status.len` is how
/// much of it arrived.
pub const Control = extern struct {
setup: [8]u8,
direction_in: u8, // 1 = device-to-host (IN), 0 = host-to-device (OUT)
reserved2: u8 = 0,
/// 1 = device-to-host (IN), 0 = host-to-device (OUT).
direction_in: u8,
_padding: u8 = 0,
/// Bytes of data stage: what an IN transfer asks for, and what an OUT
/// transfer's tail carries.
data_length: u16,
reserved3: u32 = 0,
data: [max_inline_data]u8 = [_]u8{0} ** max_inline_data,
_padding2: u32 = 0,
};
pub const ControlReply = extern struct {
status: i32, // 0 success, negative on failure/stall
actual_length: u32,
data: [max_inline_data]u8 = [_]u8{0} ** max_inline_data,
};
/// interrupt_subscribe: begin periodic IN polling of an interrupt endpoint. Each
/// report the device returns is pushed to the caller's endpoint (handed over at
/// open) as an asynchronous `InterruptReport`.
pub const InterruptSubscribeRequest = extern struct {
operation: u32 = @intFromEnum(Operation.interrupt_subscribe),
reserved: u32 = 0,
device_token: u64,
/// `interrupt_subscribe`: begin periodic IN polling of an interrupt endpoint of
/// `Header.target`. Each report the device returns is pushed to the endpoint the
/// caller handed over at `open`, as an `interrupt_report` event.
pub const InterruptSubscribe = extern struct {
endpoint_address: u8,
reserved2: u8 = 0,
max_length: u16, // bytes to request per poll (the endpoint's max packet size)
_padding: u8 = 0,
/// Bytes to request per poll (the endpoint's max packet size).
max_length: u16,
};
pub const InterruptSubscribeReply = extern struct {
status: i32,
reserved: u32 = 0,
};
/// bulk: one bulk IN or OUT transfer. `physical_address` is the class driver's own
/// `dma_alloc`'d buffer — the controller DMAs straight to/from it, so the bulk
/// data never crosses IPC. `endpoint_address`'s bit 7 selects IN vs OUT.
pub const BulkRequest = extern struct {
operation: u32 = @intFromEnum(Operation.bulk),
reserved: u32 = 0,
device_token: u64,
/// `bulk`: one bulk IN or OUT transfer on `Header.target`. `physical_address` is
/// the class driver's own `dma_alloc`'d buffer — the controller DMAs straight
/// to/from it, so the bulk data never crosses IPC. `endpoint_address`'s bit 7
/// selects IN vs OUT.
pub const Bulk = extern struct {
physical_address: u64,
length: u32,
endpoint_address: u8,
reserved2: u8 = 0,
reserved3: u16 = 0,
_padding: u8 = 0,
_padding2: u16 = 0,
};
pub const BulkReply = extern struct {
status: i32,
actual_length: u32,
};
/// How many bytes a bulk transfer actually moved. It cannot ride `Status.len`
/// the way a control transfer's does: nothing follows a bulk reply, because the
/// data went to the caller's DMA buffer rather than into the packet.
pub const Transferred = extern struct { actual_length: u32 };
/// dma_attach: the region capability rides the call's cap slot; the body only carries
/// the device token (scoping) so the controller knows which caller is attaching.
pub const DmaAttachRequest = extern struct {
operation: u32 = @intFromEnum(Operation.dma_attach),
reserved: u32 = 0,
device_token: u64,
};
pub const DmaAttachReply = extern struct {
status: i32,
reserved: u32 = 0,
};
/// An asynchronous interrupt report, pushed with `ipc.send` to a subscriber's
/// endpoint. `Received.isMessage()` is set; there is no reply owed.
/// One asynchronous interrupt report, pushed to the endpoint the class driver
/// handed over at `open`. The device it came from is `Header.target`.
pub const InterruptReport = extern struct {
device_token: u64,
endpoint_address: u8,
length: u8,
reserved: u16 = 0,
_padding: u16 = 0,
data: [max_report_data]u8 = [_]u8{0} ** max_report_data,
};
comptime {
const std = @import("std");
// Every synchronous message must fit one IPC message; the async report must
// fit an ipc_send payload slot.
std.debug.assert(@sizeOf(ControlRequest) <= message_maximum);
std.debug.assert(@sizeOf(ControlReply) <= message_maximum);
std.debug.assert(@sizeOf(OpenReply) <= message_maximum);
std.debug.assert(@sizeOf(InterruptReport) <= 64);
pub const Protocol = envelope.Define(.{
.name = "usb-transfer",
.version = 1,
.operations = &.{
// open: the target is the interface's assigned device id (its argv[1]),
// and the class driver's receive endpoint rides as the capability.
.{ .name = "open", .reply = Opened },
.{ .name = "control", .request = Control },
.{ .name = "interrupt_subscribe", .request = InterruptSubscribe },
.{ .name = "bulk", .request = Bulk, .reply = Transferred },
// dma_attach: the region capability rides the call's cap slot; the
// target says which caller's device is attaching, so there is nothing
// left for a body to carry.
.{ .name = "dma_attach" },
},
.events = &.{
.{ .name = "interrupt_report", .payload = InterruptReport },
},
});
pub const Operation = Protocol.Operation;
pub const Event = Protocol.Event;
/// What both sides size their buffers to — the call floor, as every protocol does.
pub const message_maximum: usize = Protocol.message_maximum;
test "the budgets, re-verified by Define rather than by hand" {
// What the hand-rolled comptime asserts used to say, now said by `Define`
// — and counting the header, which the old checks did not.
try std.testing.expectEqual(@as(usize, 224), max_inline_data);
try std.testing.expect(Protocol.request_maximum <= envelope.packet_maximum);
try std.testing.expect(Protocol.reply_maximum <= envelope.packet_maximum);
// The report was 48 bytes of data in a 64-byte struct that had no room left
// for a header. Folding the device token into the target and trimming the
// data to 40 leaves the whole packet at 60 of the 64-byte push floor.
try std.testing.expectEqual(@as(usize, 44), @sizeOf(InterruptReport));
try std.testing.expectEqual(@as(usize, 60), Protocol.event_maximum);
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
}
test "the verb numbering, and the device token in the header" {
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.open));
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.control));
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.interrupt_subscribe));
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Operation.bulk));
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Operation.dma_attach));
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.interrupt_report));
var buffer: [message_maximum]u8 = undefined;
const packet = Protocol.encodeRequest(.control, 9, .{
.setup = .{ 0, 6, 0, 1, 0, 0, 18, 0 },
.direction_in = 1,
.data_length = 18,
}, &.{}, &buffer).?;
try std.testing.expectEqual(@as(u64, 9), envelope.headerOf(packet).?.target);
try std.testing.expectEqual(@as(u16, 18), Protocol.decodeRequest(.control, packet).?.data_length);
}
test "a control OUT carries its data stage as the packet's tail" {
var buffer: [message_maximum]u8 = undefined;
const payload = [_]u8{ 1, 2, 3, 4 };
const packet = Protocol.encodeRequest(.control, 5, .{
.setup = .{ 0x21, 11, 0, 0, 0, 0, 4, 0 },
.direction_in = 0,
.data_length = payload.len,
}, &payload, &buffer).?;
try std.testing.expectEqualSlices(u8, &payload, Protocol.requestTail(.control, packet));
// And the answer to an IN: the bytes follow the (empty) fixed reply part,
// with `Status.len` counting exactly them.
const answered = Protocol.encodeReply(.control, 0, {}, &payload, &buffer).?;
try std.testing.expectEqual(@as(u32, payload.len), envelope.statusOf(answered).?.len);
try std.testing.expectEqualSlices(u8, &payload, Protocol.replyTail(.control, answered));
}
+145 -69
View File
@@ -1,37 +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 `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 the registry does: opening a
//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint,
//! 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
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
};
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
@@ -44,40 +34,26 @@ pub const NodeKind = enum(u32) {
symbolic_link = 4,
fifo = 5,
socket = 6,
/// A node that names a *contract*, not a file: opening it establishes a
/// channel to whatever process currently provides that protocol, delivered
/// as an endpoint capability in the reply rather than a node id. This is
/// what lives under `/protocol`; `readdir` lists these like any other node,
/// so the tree stays browsable for diagnosis.
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 {
@@ -89,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.
@@ -105,13 +152,42 @@ 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));
// Appended with the protocol namespace; every earlier value keeps its own.
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));
// 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);
}
+18 -22
View File
@@ -1,6 +1,6 @@
//! The **private kernel ↔ runtime** ABI: the raw system_call contract — the call
//! numbers, `mmap` protection flags, the page size those calls work in, and the IPC
//! name-registry ids and notification bit. Shared by the kernel dispatcher
//! notification bits. Shared by the kernel dispatcher
//! (system/kernel/process.zig) and the user-space runtime library (library/runtime/),
//! so the two can never drift.
//!
@@ -33,8 +33,13 @@ pub const SystemCall = enum(u64) {
mmap = 4, // mmap(len, prot) -> base: grant zeroed, page-aligned user pages
munmap = 5, // munmap(base, len): release pages from a prior mmap
create_ipc_endpoint = 6, // create_ipc_endpoint() -> handle: a new IPC endpoint
ipc_register = 7, // ipc_register(service_id, handle): publish an endpoint by well-known id
ipc_lookup = 8, // ipc_lookup(service_id) -> handle: find a published endpoint
// 7 and 8 were ipc_register/ipc_lookup — the flat ServiceId name registry,
// retired with the protocol namespace (docs/os-development/protocol-namespace.md).
// A service now binds its name at the registry (init, over /protocol) and a
// client resolves and opens that path; neither is a system call any more. The
// numbers stay vacant rather than being reused: every other entry is
// position-fixed by an explicit value, so a hole costs nothing and a reused
// number would silently mean two things across a rebuild boundary.
ipc_call = 9, // ipc_call(h, message, len, reply, cap) -> reply_len: send + block for reply
ipc_reply_wait = 10, // ipc_reply_wait(h, reply, len, receive, cap) -> receive_len (+badge in rdx)
device_enumerate = 11, // device_enumerate(buffer, maximum) -> count: snapshot the device table
@@ -247,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;
@@ -284,23 +292,11 @@ pub const KlogStatus = extern struct {
boot_unix_seconds: u64, // wall-clock time of boot (RTC anchor)
};
/// Well-known IPC service ids for the bootstrap name registry (create_ipc_endpoint +
/// ipc_register/ipc_lookup). Small integers, so no string interning is needed
/// during bring-up. The VFS server registers under `vfs`; clients look it up.
pub const ServiceId = enum(u32) {
vfs = 1, // RETIRED: the router moved into the kernel (fs_resolve); the slot stays reserved
input = 2,
ps2_bus = 3, // the 8042 owner; child device drivers attach here for raw bytes
device_manager = 4, // the tree, the matcher, the supervisor (docs/device-manager.md)
power = 5, // system power: events (button, lid, battery) + shutdown (docs/power.md; domain-named per docs/discovery.md — the acpi service registers it on x86, a PSCI service will on ARM)
usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md)
block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on
fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/volumes/usb) to it
display = 9, // the display service: owns the framebuffer, composites a layer stack, presents frames (docs/display.md)
shared_memory_test = 10, // the shared-memory test server (V2): a client passes it a shared-memory capability, it maps + verifies (docs/display-v2.md)
scanout = 11, // a native scanout driver (virtio-gpu): the compositor finds it here to upgrade off the GOP framebuffer (docs/display-v2.md)
_,
};
// The `ServiceId` enum lived here: a flat, compile-time list of well-known
// service ids backed by a 16-slot kernel table. It is gone with the protocol
// namespace — names are strings resolved under `/protocol` at run time, so a
// third-party program can introduce a contract the ABI never heard of, and the
// registrar (init) decides who may claim one.
/// Protection flags for `mmap` (matching the usual C bit values).
pub const prot_read: u64 = 1;
+175
View File
@@ -0,0 +1,175 @@
# /system/configuration/protocol.csv — who may claim, and who may reach, a name
# under /protocol (docs/os-development/protocol-namespace.md).
#
# init is the registrar: it serves /protocol, and every bind AND every open is
# checked against this file. It is AUTHORITATIVE — a name no row grants cannot be
# bound or reached, and a missing file means nothing may be bound or reached at
# all.
#
# A refused open is answered exactly as a name nobody bound is: -ENOENT, and no
# capability. That is not politeness, it is the model — the namespace IS the
# restriction, so what a process may not open simply does not exist for it, and
# there is no "permission denied" for it to tell apart from "no such contract".
# Which is why a missing row here shows up as a client retrying forever rather
# than as an error: check this file first, and `readdir /protocol` second.
#
# '#' starts a comment (whole-line or trailing); blank lines are ignored.
# Whitespace around a field is trimmed, so columns may be padded. Four
# comma-separated fields per row:
#
# binary the claimant's binary path, exactly as the kernel stamped it at
# spawn (argv[0]) — unforgeable, read from the process records
# supervisor the authorized supervising TASK, written as the binary it runs —
# the path init was started as for its own services, the device
# manager's path for the drivers it starts. The one word that is not
# a path is 'kernel', because a kernel task has no binary; that is
# what the test harness's direct spawns look like.
# Matched by IDENTITY, not by spelling. Name alone is not identity —
# spawn is ungated, so a hostile process can start a granted binary
# itself and inherit its grants; and it can equally start its own
# instance of the *supervisor's* binary and have that spawn the
# granted one, at which point both names read correctly (the
# laundering deputy). So init also asks which task the supervisor
# is: 'kernel' means supervisor id 0, which only the kernel can
# confer; init's own path means this init; any other path means a
# task init spawned itself or one the kernel spawned. Task ids are
# monotonic and never reused, so an id cannot be borrowed.
# permission bind (provide this contract) | open (speak to it) |
# supervise (stand in someone else's chain — see below)
# name the contract, relative to /protocol
#
# A trailing '*' on any field matches any tail — how a subtree is granted whole.
#
# 'supervise' exists because attestation is one hop deep and the driver tree is
# three: the device manager starts the PS/2 bus, and the bus starts the keyboard
# and mouse drivers. Init never met the bus, so it cannot vouch for it by
# acquaintance — and it must not vouch for it by name, or the laundering deputy
# walks straight in. A 'supervise' row is the manifest saying it: a task running
# this binary, under this supervisor, may be the supervising task an 'open' row
# names, for this contract and no other. It grants the delegate nothing itself,
# and it is deliberately open-only — a delegate may vouch for what its children
# REACH, never for what they CLAIM, so every bind refusal is untouched by it.
#
# binary supervisor permission name
# --- the services init spawns from init.csv ---------------------------------
/system/services/input, /system/services/init, bind, input
/system/services/device-manager, /system/services/init, bind, device-manager
/system/services/fat, /system/services/init, bind, vfs
/system/services/display, /system/services/init, bind, display
# The discovery service ships under one neutral name per firmware (docs/discovery.md);
# on x86 it is the acpi service, and what it provides is the power contract.
/system/services/discovery, /system/services/device-manager, bind, power
# --- the drivers, which the device manager spawns ---------------------------
/system/drivers/ps2-bus, /system/services/device-manager, bind, ps2-bus
/system/drivers/usb-xhci-bus, /system/services/device-manager, bind, usb-transfer
/system/drivers/usb-storage, /system/services/device-manager, bind, block
/system/drivers/virtio-gpu, /system/services/device-manager, bind, scanout
# --- the same providers when the kernel test harness starts them directly ---
# A scenario boot spawns its own providers instead of letting init do it
# (docs/security-track-plan.md, decision 9), so the same binaries appear with
# 'kernel' as the supervisor. Nothing else changes: the binary must still match.
/system/services/input, kernel, bind, input
/system/services/device-manager, kernel, bind, device-manager
/system/services/fat, kernel, bind, vfs
/system/services/display, kernel, bind, display
/system/services/discovery, kernel, bind, power
# --- test fixtures ----------------------------------------------------------
# The subtree rule, dogfooded: anything installed under /test may claim anything
# under /protocol/test, and nothing above it — whether the harness spawned it or
# another fixture did.
/test/*, kernel, bind, test/*
/test/*, /test/*, bind, test/*
# ============================================================================
# open — who may REACH each contract. One row per client per contract; a client
# with no row here simply finds the name absent, forever.
# ============================================================================
# --- init's own services ----------------------------------------------------
# fat reaches the block device behind the volume it mounts; the compositor
# reaches the scanout its driver announced, its own endpoint (the mouse-listener
# thread opens /protocol/display like any other client — threads share no
# handles), and the input stream that moves the cursor.
/system/services/fat, /system/services/init, open, block
/system/services/display, /system/services/init, open, scanout
/system/services/display, /system/services/init, open, display
/system/services/display, /system/services/init, open, input
/system/services/display-demo, /system/services/init, open, display
# --- the same two when the kernel test harness starts them directly ---------
/system/services/display, kernel, open, scanout
/system/services/display, kernel, open, display
/system/services/display, kernel, open, input
/system/services/display-demo, kernel, open, display
# --- the drivers, and the discovery service ---------------------------------
# Every driver says hello to the manager that started it — one row for the whole
# subtree, because that handshake is what being a driver means. The rest are per
# driver: the storage and HID class drivers talk to their controller, the HID
# drivers publish into the input stream, and the GPU driver announces its scanout
# to the compositor.
/system/drivers/*, /system/services/device-manager, open, device-manager
/system/services/discovery, /system/services/device-manager, open, device-manager
/system/drivers/usb-storage, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, input
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, input
/system/drivers/virtio-gpu, /system/services/device-manager, open, display
# --- the PS/2 child drivers, one hop further down ---------------------------
# The keyboard and mouse drivers are started by the BUS driver, not by the
# device manager — the one three-deep chain in the tree. Init cannot vouch for
# the bus by acquaintance (it never started it), so the manifest authorizes it
# explicitly, and only for the two contracts its children need.
/system/drivers/ps2-bus, /system/services/device-manager, supervise, ps2-bus
/system/drivers/ps2-bus, /system/services/device-manager, supervise, input
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, input
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, input
# --- test fixtures ----------------------------------------------------------
# The /protocol/test subtree is theirs whole, the way the bind rows give it to
# them. Everything ABOVE that subtree is named one fixture at a time, so a
# fixture reaches a system contract only where a scenario needs it — which is
# what leaves the rest genuinely absent for the rest of them (the protocol-denied
# case asks for one it was not given, and is told there is no such thing).
/test/*, kernel, open, test/*
/test/*, /test/*, open, test/*
/test/*, kernel, open, device-manager
/test/*, /system/services/device-manager, open, device-manager
/test/system/services/input-source, kernel, open, input
/test/system/services/input-test, kernel, open, input
# The guessable-id probe (test/system/services/badge-scope-test) runs as two
# processes of one binary: the owner, which the scenario spawns, and the intruder,
# which the owner spawns with the ids it holds. Both reach the compositor — the
# owner to create the layer, the intruder to be refused it — so the binary is
# named twice, once per supervisor. The second row needs no 'supervise'
# delegation: the owner was spawned by the KERNEL, which is a chain init can
# vouch for on its own.
/test/system/services/badge-scope-test, kernel, open, display
/test/system/services/badge-scope-test, /test/*, open, display
# 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
# it got, so its reporting channel, and nothing else, is delegated.
/test/*, /test/*, supervise, test/verdict
Can't render this file because it contains an unexpected character in line 12 and column 15.
+9 -6
View File
@@ -229,27 +229,30 @@ fn registerAndReport(bus: u64, dev: u64, function: u64, class_triple: u32) void
std.log.info("register refused for {d}:{d}.{d}", .{ bus, dev, function });
return;
};
const report = device_manager_protocol.ChildAdded{
// The registered device id is the packet's target — the manager's object
// addressing — so the report body carries only where on the bus it sits and
// what it is.
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(.child_added, registered, .{
.bus = @intFromEnum(device_manager_protocol.BusKind.pci),
.parent = bridge_id,
.bus_address = (bus << 8) | (dev << 3) | function,
.identity = class_triple,
.device_id = registered,
.vendor = descriptor.vendor,
.device = descriptor.device,
.subsystem = descriptor.subsystem,
};
}, &.{}, &packet) orelse return;
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(manager_handle, std.mem.asBytes(&report), &reply) catch {
_ = ipc.call(manager_handle, framed, &reply) catch {
std.log.info("child report for {d}:{d}.{d} failed", .{ bus, dev, function });
};
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = message;
_ = reply;
_ = sender;
_ = capability;
_ = arrived; // nothing here takes a capability: the harness closes what arrives
return 0;
}
+5 -5
View File
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const ps2_bus_exe = build_support.userBinary(b, .{
.name = "ps2-bus",
.root_source_file = b.path("ps2-bus.zig"),
.imports = &.{ "acpi-ids", "driver", "ipc", "logging", "memory", "process", "service", "time" },
.imports = &.{ "acpi-ids", "channel", "driver", "ipc", "logging", "memory", "process", "service", "time" },
});
b.installArtifact(ps2_bus_exe);
@@ -17,8 +17,8 @@ pub fn build(b: *std.Build) void {
.name = "ps2-keyboard",
.root_source_file = b.path("keyboard.zig"),
.imports = &.{
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory",
"process", "time", "xkeyboard-config",
"acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc",
"logging", "memory", "process", "time", "xkeyboard-config",
},
});
b.installArtifact(ps2_keyboard_exe);
@@ -27,8 +27,8 @@ pub fn build(b: *std.Build) void {
.name = "ps2-mouse",
.root_source_file = b.path("mouse.zig"),
.imports = &.{
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory",
"process", "time",
"acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc", "logging",
"memory", "process", "time",
},
});
b.installArtifact(ps2_mouse_exe);
+4 -3
View File
@@ -16,6 +16,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const time = @import("time");
@@ -27,12 +28,12 @@ const ps2 = @import("ps2-library.zig");
const scancode = @import("scancode.zig");
const input_protocol = @import("input-protocol");
/// Look up the ps2-bus service, retrying while the bus (which spawned us before
/// registering) is still coming up.
/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
/// binding) is still coming up.
fn lookupBus() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.ps2_bus)) |handle| return handle;
if (channel.openEndpoint("ps2-bus")) |handle| return handle;
time.sleepMillis(50);
}
return null;
+4 -3
View File
@@ -16,6 +16,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const time = @import("time");
@@ -26,12 +27,12 @@ const ps2 = @import("ps2-library.zig");
const mouse_packet = @import("mouse-packet.zig");
const input_protocol = @import("input-protocol");
/// Look up the ps2-bus service, retrying while the bus (which spawned us before
/// registering) is still coming up.
/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
/// binding) is still coming up.
fn lookupBus() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.ps2_bus)) |handle| return handle;
if (channel.openEndpoint("ps2-bus")) |handle| return handle;
time.sleepMillis(50);
}
return null;
+29 -9
View File
@@ -11,6 +11,7 @@
//! - irq 0xc len 0x1
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -62,7 +63,13 @@ var port_device_types = [_]?ps2.DeviceType{ null, null };
/// Handle a child driver's `AttachRequest`: record the endpoint capability it
/// passed as the forwarding target for the port whose device matches its type.
/// Writes an `AttachReply` into `out` and returns its length.
fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize {
/// A child driver's AttachRequest. The endpoint it hands over arrives under the
/// same ownership rule the service harness states (`ipc.Arrival`): the turn owns
/// it, and only the path that records it in `port_endpoints` says `take`. Every
/// refusal here simply returns, and the loop closes what arrived — otherwise a
/// stranger (this is a named contract, reachable by anyone) spends one of this
/// driver's thirty-two handle slots per malformed attach.
fn handleAttach(message: []const u8, out: []u8, arrived: *ipc.Arrival) usize {
const reply = struct {
fn write(buffer: []u8, status: ps2.AttachStatus) usize {
const header = ps2.AttachReply{ .status = @intFromEnum(status) };
@@ -73,12 +80,17 @@ fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize {
if (message.len < @sizeOf(ps2.AttachRequest)) return reply.write(out, .invalid_request);
const request = std.mem.bytesToValue(ps2.AttachRequest, message[0..@sizeOf(ps2.AttachRequest)]);
const endpoint = got.cap orelse return reply.write(out, .missing_endpoint);
const endpoint = arrived.peek() orelse return reply.write(out, .missing_endpoint);
for (&port_device_types, 0..) |maybe_type, port_index| {
const device_type = maybe_type orelse continue;
if (@intFromEnum(device_type) != request.device_type) continue;
port_endpoints[port_index] = endpoint;
// Claimed. A re-attach supersedes the previous driver's endpoint, and the
// one it displaces is closed: the slot holds exactly one reference.
if (port_endpoints[port_index]) |previous| {
if (previous != endpoint) _ = ipc.close(previous);
}
port_endpoints[port_index] = arrived.take();
std.log.info("{s} driver attached", .{@tagName(device_type)});
return reply.write(out, .ok);
}
@@ -213,15 +225,16 @@ pub fn main() void {
return;
};
// The endpoint the child drivers attach to and IRQ1 wakes. Registered under a
// well-known id so the children can find it, the way input subscribers find
// the input service.
// The endpoint the child drivers attach to and IRQ1 wakes. Bound as the
// `ps2-bus` contract so the children can find it by name, the way input
// subscribers find the input service. This driver runs its own loop rather
// than the service harness, so it binds by hand — same call the harness makes.
const endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("/system/drivers/ps2-bus: no endpoint\n");
return;
};
if (!ipc.register(.ps2_bus, endpoint)) {
_ = logging.write("/system/drivers/ps2-bus: register failed\n");
if (!channel.bindPatiently("ps2-bus", endpoint)) {
_ = logging.write("/system/drivers/ps2-bus: could not bind /protocol/ps2-bus\n");
return;
}
@@ -273,6 +286,13 @@ pub fn main() void {
var receive: [@sizeOf(ps2.AttachRequest)]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
// The turn owns whatever capability arrived and closes it unless
// `handleAttach` claims it (`ipc.Arrival`) — the kernel installs one
// whatever the message's length or kind, so this covers the notification
// path and every refusal below it.
var arrived: ipc.Arrival = .{ .handle = got.cap };
defer arrived.release();
if (got.isNotification()) {
reply_len = 0;
if (got.isMessage() or got.isChildExit()) continue; // nothing sends us these
@@ -301,6 +321,6 @@ pub fn main() void {
}
continue;
}
reply_len = handleAttach(receive[0..got.len], got, &reply_buffer);
reply_len = handleAttach(receive[0..got.len], &reply_buffer, &arrived);
}
}
+4 -3
View File
@@ -112,9 +112,10 @@ pub fn main(init: process.Init) void {
if (signals.has(.terminate)) return;
continue;
}
if (!got.isMessage() or got.len < @sizeOf(usb.InterruptReport)) continue;
const message = std.mem.bytesToValue(usb.InterruptReport, receive[0..@sizeOf(usb.InterruptReport)]);
if (!got.isMessage()) continue;
// An `interrupt_report` event packet: the verb in its folded header, the
// report after it. Anything else on this endpoint is not ours.
const message = usb.reportOf(receive[0..got.len]) orelse continue;
if (message.length < @sizeOf(hid.KeyboardReport)) continue;
const report = std.mem.bytesToValue(hid.KeyboardReport, message.data[0..@sizeOf(hid.KeyboardReport)]);
const transitions = decoder.feed(report);
+4 -3
View File
@@ -74,9 +74,10 @@ pub fn main(init: process.Init) void {
if (signals.has(.terminate)) return;
continue;
}
if (!got.isMessage() or got.len < @sizeOf(usb.InterruptReport)) continue;
const message = std.mem.bytesToValue(usb.InterruptReport, receive[0..@sizeOf(usb.InterruptReport)]);
if (!got.isMessage()) continue;
// An `interrupt_report` event packet: the verb in its folded header, the
// report after it. Anything else on this endpoint is not ours.
const message = usb.reportOf(receive[0..got.len]) orelse continue;
const length = @min(message.length, message.data.len);
const report = hid.parseMouse(message.data[0..length]) orelse continue;
const mask = buttonMask(report.buttons);
+2 -2
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);
+66 -45
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,52 +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, capability: ?ipc.Handle) 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, then release our copy (the binding holds a ref).
const handle = capability orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 });
const ok = device.attachDma(handle);
_ = ipc.close(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 {
@@ -202,7 +223,7 @@ pub fn main(init: process.Init) void {
return;
};
service.run(block_protocol.message_maximum, .{
.service = .block,
.service = "block",
.init = initialise,
.on_message = onMessage,
});
+4 -3
View File
@@ -10,9 +10,10 @@ pub fn build(b: *std.Build) void {
.name = "usb-xhci-bus",
.root_source_file = b.path("usb-xhci-bus.zig"),
.imports = &.{
"device-manager-protocol", "driver", "input-client", "ipc", "logging", "memory",
"mmio", "pci", "process", "service", "time", "usb-abi", "usb-ids",
"usb-transfer-protocol",
"channel", "device-manager-protocol", "driver", "envelope",
"input-client", "ipc", "logging", "memory",
"mmio", "pci", "process", "service",
"time", "usb-abi", "usb-ids", "usb-transfer-protocol",
},
});
b.installArtifact(exe);
+225 -108
View File
@@ -15,6 +15,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -24,6 +25,7 @@ const device_manager = @import("driver");
const memory = @import("memory");
const logging = @import("logging");
const device_manager_protocol = @import("device-manager-protocol");
const envelope = @import("envelope");
const usb_ids = @import("usb-ids");
const usb_abi = @import("usb-abi");
const usb_transfer_protocol = @import("usb-transfer-protocol");
@@ -59,28 +61,65 @@ fn timerInterval() u64 {
return if (msi_vector != null) reconcile_interval_ms else poll_interval_ms;
}
/// The class driver endpoints that opened each device, so interrupt reports can
/// be pushed back to them. Keyed by the device token (the interface's device id).
/// The class driver that opened each device: the endpoint interrupt reports are
/// pushed back to, and **the task that opened it** — the kernel-stamped badge, so
/// a device token is scoped to the client that was given it. Keyed by the device
/// token (the interface's device id).
///
/// The scoping is the point (docs/os-development/protocol-namespace.md: handles
/// are validated against the badge). A token is a small registered-device id any
/// process could name, and every packet that carries one used to be honoured from
/// anyone: a stranger could run control transfers on another driver's device, arm
/// interrupt polling on it, or redirect its reports.
const Open = struct {
used: bool = false,
device_token: u64 = 0,
owner: u32 = 0,
report_endpoint: usize = 0,
};
var opens = [_]Open{.{}} ** 16;
fn recordOpen(device_token: u64, report_endpoint: usize) void {
/// Remember (or replace) the endpoint task `owner` receives reports for
/// `device_token` on. Returns whether the table kept the handle — false means the
/// caller still owns it and must dispose of it. A re-open by the **same** client
/// supersedes its previous endpoint, and the one it displaced is closed here: the
/// table holds exactly one reference per slot.
fn recordOpen(device_token: u64, owner: u32, report_endpoint: usize) bool {
for (&opens) |*open| {
if (open.used and open.device_token == device_token) {
if (open.owner != owner) return false; // someone else's device; nothing kept
if (open.report_endpoint != report_endpoint) _ = ipc.close(open.report_endpoint);
open.report_endpoint = report_endpoint;
return;
return true;
}
}
for (&opens) |*open| {
if (!open.used) {
open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint };
return;
open.* = .{ .used = true, .device_token = device_token, .owner = owner, .report_endpoint = report_endpoint };
return true;
}
}
return false; // table full: not kept
}
/// Whether `device_token` is open to task `owner`. An open device belonging to
/// someone else answers exactly as one that was never opened, so a prober cannot
/// tell another driver's device from an absent one.
fn openedBy(device_token: u64, owner: u32) bool {
for (&opens) |*open| {
if (open.used and open.device_token == device_token) return open.owner == owner;
}
return false;
}
/// Whether `device_token` is open at all. Asked only after `openedBy` has said
/// the caller is not the holder, so an answer of true means *someone else* holds
/// it — one device, one class driver.
fn heldByAnother(device_token: u64) bool {
for (&opens) |*open| {
if (open.used and open.device_token == device_token) return true;
}
return false;
}
fn reportEndpointFor(device_token: u64) ?usize {
@@ -90,6 +129,23 @@ fn reportEndpointFor(device_token: u64) ?usize {
return null;
}
/// Release every device a dead client held: its slot, and the report endpoint
/// capability in it. Driven by published process exits — the same sweep idiom the
/// FAT server uses for open files and the harness uses for subscribers — which is
/// also what lets a restarted class driver re-open the device its predecessor had.
fn releaseOpensOf(dead: u32) void {
for (&opens) |*open| {
if (open.used and open.owner == dead) {
// The engine first: it holds this endpoint's handle number per
// subscription, and the close below frees that number for reuse.
if (controller) |*engine| engine.releaseSubscriptions(open.device_token);
_ = ipc.close(open.report_endpoint);
std.log.info("released device {d} for dead client {d}", .{ open.device_token, dead });
open.* = .{};
}
}
}
var controller_id: u64 = device_manager_protocol.no_device;
/// Claim the assigned controller, find its register window, and hello the
@@ -97,6 +153,26 @@ var controller_id: u64 = device_manager_protocol.no_device;
/// manager reads as "meant to stop" — a missing assignment is not a crash loop.
fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint;
// Device tokens are per-client state, so this driver needs deaths for the
// same reason the FAT server does: a class driver that crashes must not keep
// its device open, or its restarted instance could never claim it back.
_ = process.subscribeExits(endpoint);
// The transfer contract, bound by hand rather than through the harness's
// `.service`, because **losing it is not fatal here**. One machine can carry
// several xHCI controllers and the driver model spawns one process per
// controller, so several processes provide the same contract for different
// hardware — and `/protocol` holds exactly one name, deliberately (addressing
// lives inside the protocol, never in the path). Whoever binds first is the
// one clients reach by name; a later instance still owns its controller,
// enumerates its bus, and reports its children to the device manager, so it
// keeps running. **Known gap:** a class driver behind a second controller
// cannot reach it — the transfer protocol has no controller field for
// `target`, and the fix is either one process multiplexing every controller
// or the spawner wiring the child's channel (P5), not a second name.
if (!channel.bindPatiently("usb-transfer", endpoint))
_ = logging.write("/system/drivers/usb-xhci-bus: /protocol/usb-transfer is another controller's; serving mine unnamed\n");
if (!device.claim(controller_id)) {
std.log.info("unable to claim controller device {d}", .{controller_id});
return false;
@@ -363,6 +439,23 @@ fn bringUpBehindHub(manager: ipc.Handle, engine: *library.Controller, hub: *libr
if (deviceIsHub(usb_device)) _ = engine.setupHub(usb_device);
}
/// Report one interface gone. A removal is addressed by the composite (parent,
/// bus address) — a pair no single `Header.target` can carry — so that stays the
/// packet's body and the target addresses the manager itself. `where` names the
/// port and interface for the log line, or null where the caller stays quiet
/// (a hub subtree collapsing reports a great many at once).
fn reportRemoved(manager: ipc.Handle, bus_address: u64, where: ?struct { port: u32, interface: u8 }) void {
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(.child_removed, 0, .{
.parent = controller_id,
.bus_address = bus_address,
}, &.{}, &packet) orelse return;
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(manager, framed, &reply) catch {
if (where) |place| std.log.info("child-removed report for port {d} interface {d} failed", .{ place.port, place.interface });
};
}
/// Tear down a device that disconnected from a hub: recursively tear down its
/// own downstream devices first if it is a hub, report each interface removed,
/// then Disable Slot. Mirrors tearDownPort for a hub-attached device.
@@ -375,12 +468,7 @@ fn tearDownHubDevice(manager: ipc.Handle, engine: *library.Controller, dev: *lib
const key = hubPortKey(dev.parent_slot, dev.parent_port);
for (dev.interfaces[0..dev.interface_count]) |*interface| {
if (interface.registered_device_id == 0) continue;
const event = device_manager_protocol.ChildRemoved{
.parent = controller_id,
.bus_address = (@as(u64, key) << 8) | interface.number,
};
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(manager, std.mem.asBytes(&event), &reply) catch {};
reportRemoved(manager, (@as(u64, key) << 8) | interface.number, null);
interface.registered_device_id = 0;
}
engine.tearDownDevice(dev);
@@ -405,14 +493,7 @@ fn tearDownPort(manager: ipc.Handle, engine: *library.Controller, port: u32) voi
std.log.info("port {d} disconnected", .{port});
for (usb_device.interfaces[0..usb_device.interface_count]) |*interface| {
if (interface.registered_device_id == 0) continue;
const event = device_manager_protocol.ChildRemoved{
.parent = controller_id,
.bus_address = (@as(u64, port) << 8) | interface.number,
};
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(manager, std.mem.asBytes(&event), &reply) catch {
std.log.info("child-removed report for port {d} interface {d} failed", .{ port, interface.number });
};
reportRemoved(manager, (@as(u64, port) << 8) | interface.number, .{ .port = port, .interface = interface.number });
interface.registered_device_id = 0;
}
engine.tearDownDevice(usb_device);
@@ -446,15 +527,17 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI
return null;
};
const report = device_manager_protocol.ChildAdded{
// The registered device id is the packet's target — the manager's object
// addressing — so the report body carries only where on the bus it sits.
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(.child_added, registered, .{
.bus = @intFromEnum(device_manager_protocol.BusKind.usb),
.parent = controller_id,
.bus_address = (@as(u64, port) << 8) | interface.number,
.identity = identity,
.device_id = registered,
};
}, &.{}, &packet) orelse return null;
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(manager, std.mem.asBytes(&report), &reply) catch {
_ = ipc.call(manager, framed, &reply) catch {
std.log.info("child report for port {d} interface {d} failed", .{ port, interface.number });
return null;
};
@@ -473,54 +556,60 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI
return registered;
}
/// Serve the USB transfer protocol: a class driver opens its device, then issues
/// control / interrupt-subscribe / bulk requests against it.
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = sender;
if (message.len < 4) return 0;
const operation = std.mem.readInt(u32, message[0..4], .little);
return switch (operation) {
@intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, capability),
@intFromEnum(usb_transfer_protocol.Operation.control) => handleControl(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.interrupt_subscribe) => handleSubscribe(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.bulk) => handleBulk(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, capability),
else => 0,
};
/// The generated transfer dispatch. One controller per process, so the handler
/// context is empty and the open table stays in this file's globals.
const Serve = usb_transfer_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// Every refusal here is the same one — this controller does not (or no longer)
/// serve the device the packet addressed — so there is one errno for all of them.
const refused: isize = -envelope.ENOENT;
/// Set by `onOpen` when the open table has taken ownership of the endpoint the
/// call carried, and read by `onMessage`, where the turn's `Arrival` lives. The
/// generated dispatch hands a handler the raw handle rather than the `Arrival`,
/// so the *claim* travels back out this way. One turn, one handler, one thread.
var capability_claimed = false;
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
capability_claimed = false;
const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
if (capability_claimed) _ = arrived.take();
return written;
}
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU
/// domain, so the controller may DMA to the physical addresses inside that buffer. The
/// binding holds its own kernel reference, so the forwarded capability is closed here.
fn handleDmaAttach(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
if (message.len < @sizeOf(usb_transfer_protocol.DmaAttachRequest)) return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
const handle = capability orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
const ok = device.dmaBind(controller_id, handle);
_ = ipc.close(handle);
return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = if (ok) 0 else -1 });
}
const handlers = Serve.Handlers{
.open = onOpen,
.control = onControl,
.interrupt_subscribe = onInterruptSubscribe,
.bulk = onBulk,
.dma_attach = onDmaAttach,
};
fn writeReply(reply: []u8, value: anytype) usize {
const bytes = std.mem.asBytes(&value);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
}
/// open: the target is the class driver's assigned device id. Resolve it to an
/// interface, remember the caller's endpoint (for interrupt reports), and answer
/// with a device token — the target of every later packet — plus the interface's
/// endpoints, so the class driver need not re-read the configuration descriptor.
fn onOpen(_: void, invocation: Invocation(void), answer: Answer(usb_transfer_protocol.Opened)) isize {
const engine = if (controller) |*c| c else return refused;
const found = engine.findInterface(invocation.target) orelse return refused;
// A device another live client holds is refused exactly as an absent one: one
// device, one class driver. The predecessor's slot is released by the exit
// sweep, and notifications are delivered ahead of requests, so a *restarted*
// driver's open always finds the device free.
if (!openedBy(invocation.target, invocation.sender) and heldByAnother(invocation.target)) return refused;
/// open: resolve the assigned device id to an interface, remember the caller's
/// endpoint (for interrupt reports), and answer with a device token + the
/// interface's endpoints so the class driver need not re-read the config.
fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
if (message.len < @sizeOf(usb_transfer_protocol.OpenRequest)) return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
const request = std.mem.bytesToValue(usb_transfer_protocol.OpenRequest, message[0..@sizeOf(usb_transfer_protocol.OpenRequest)]);
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
const found = engine.findInterface(request.device_id) orelse return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
// The report endpoint is claimed only if the open table actually keeps it;
// a full table leaves it to the turn to close.
if (invocation.capability) |endpoint| {
if (recordOpen(invocation.target, invocation.sender, endpoint)) capability_claimed = true;
}
if (capability) |endpoint| recordOpen(request.device_id, endpoint);
var open_reply = usb_transfer_protocol.OpenReply{
.status = 0,
var opened = usb_transfer_protocol.Opened{
.device_token = invocation.target,
.endpoint_count = found.interface.endpoint_count,
.device_token = request.device_id,
.interface_class = found.interface.class,
.interface_subclass = found.interface.subclass,
.interface_protocol = found.interface.protocol,
@@ -528,57 +617,74 @@ fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
};
const count = @min(found.interface.endpoint_count, usb_transfer_protocol.max_reported_endpoints);
for (found.interface.endpoints[0..count], 0..) |endpoint, index| {
open_reply.endpoints[index] = .{
opened.endpoints[index] = .{
.address = endpoint.address,
.transfer_type = endpoint.transfer_type,
.max_packet_size = endpoint.max_packet_size,
.interval = endpoint.interval,
};
}
return writeReply(reply, open_reply);
answer.set(opened);
return 0;
}
/// control: one EP0 control transfer, small data inline both ways.
fn handleControl(message: []const u8, reply: []u8) usize {
if (message.len < @sizeOf(usb_transfer_protocol.ControlRequest)) return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
const request = std.mem.bytesToValue(usb_transfer_protocol.ControlRequest, message[0..@sizeOf(usb_transfer_protocol.ControlRequest)]);
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
/// control: one EP0 control transfer. The data stage rides the tail in both
/// directions, so an IN transfer's answer is simply however many bytes were
/// written into `answer.tail()` — which is what `Status.len` then reports.
fn onControl(_: void, invocation: Invocation(usb_transfer_protocol.Control), answer: Answer(void)) isize {
const engine = if (controller) |*c| c else return refused;
if (!openedBy(invocation.target, invocation.sender)) return refused;
const found = engine.findInterface(invocation.target) orelse return refused;
const setup = std.mem.bytesToValue(usb_abi.Request, &invocation.request.setup);
const direction_in = invocation.request.direction_in != 0;
const room = @min(usb_transfer_protocol.max_inline_data, answer.tail().len);
const data_length = @min(@as(usize, invocation.request.data_length), room);
const setup = std.mem.bytesToValue(usb_abi.Request, &request.setup);
const direction_in = request.direction_in != 0;
const data_length = @min(request.data_length, usb_transfer_protocol.max_inline_data);
var data: [usb_transfer_protocol.max_inline_data]u8 = undefined;
if (!direction_in) @memcpy(data[0..data_length], request.data[0..data_length]);
if (!direction_in) {
const supplied = @min(data_length, invocation.tail.len);
@memcpy(data[0..supplied], invocation.tail[0..supplied]);
if (supplied < data_length) @memset(data[supplied..data_length], 0);
}
const ok = engine.controlTransfer(found.device, setup, data[0..data_length], direction_in);
var control_reply = usb_transfer_protocol.ControlReply{ .status = if (ok) 0 else -1, .actual_length = if (ok) data_length else 0 };
if (ok and direction_in) @memcpy(control_reply.data[0..data_length], data[0..data_length]);
return writeReply(reply, control_reply);
if (!engine.controlTransfer(found.device, setup, data[0..data_length], direction_in)) return refused;
if (!direction_in) return 0; // nothing follows an OUT: the status is the whole answer
@memcpy(answer.tail()[0..data_length], data[0..data_length]);
return @intCast(data_length);
}
/// interrupt_subscribe: arm periodic IN polling; reports flow back asynchronously.
fn handleSubscribe(message: []const u8, reply: []u8) usize {
if (message.len < @sizeOf(usb_transfer_protocol.InterruptSubscribeRequest)) return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
const request = std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeRequest, message[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeRequest)]);
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
const endpoint = library.Controller.endpointForAddress(found.interface, request.endpoint_address) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
const report_endpoint = reportEndpointFor(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
const ok = engine.subscribeInterrupt(found.device, endpoint, request.device_token, report_endpoint);
return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = if (ok) 0 else -1 });
/// interrupt_subscribe: arm periodic IN polling; reports flow back asynchronously
/// to the endpoint this device's `open` handed over.
fn onInterruptSubscribe(_: void, invocation: Invocation(usb_transfer_protocol.InterruptSubscribe), _: Answer(void)) isize {
const engine = if (controller) |*c| c else return refused;
if (!openedBy(invocation.target, invocation.sender)) return refused;
const found = engine.findInterface(invocation.target) orelse return refused;
const endpoint = library.Controller.endpointForAddress(found.interface, invocation.request.endpoint_address) orelse return refused;
const report_endpoint = reportEndpointFor(invocation.target) orelse return refused;
return if (engine.subscribeInterrupt(found.device, endpoint, invocation.target, report_endpoint)) 0 else refused;
}
/// bulk: one bulk transfer to/from the class driver's own DMA buffer (by physical
/// address), so sector-sized data never crosses IPC.
fn handleBulk(message: []const u8, reply: []u8) usize {
if (message.len < @sizeOf(usb_transfer_protocol.BulkRequest)) return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
const request = std.mem.bytesToValue(usb_transfer_protocol.BulkRequest, message[0..@sizeOf(usb_transfer_protocol.BulkRequest)]);
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
const endpoint = library.Controller.endpointForAddress(found.interface, request.endpoint_address) orelse return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
const transferred = engine.bulkTransfer(found.device, endpoint, request.physical_address, request.length);
return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = if (transferred != null) 0 else -1, .actual_length = transferred orelse 0 });
fn onBulk(_: void, invocation: Invocation(usb_transfer_protocol.Bulk), answer: Answer(usb_transfer_protocol.Transferred)) isize {
const engine = if (controller) |*c| c else return refused;
if (!openedBy(invocation.target, invocation.sender)) return refused;
const found = engine.findInterface(invocation.target) orelse return refused;
const endpoint = library.Controller.endpointForAddress(found.interface, invocation.request.endpoint_address) orelse return refused;
const transferred = engine.bulkTransfer(found.device, endpoint, invocation.request.physical_address, invocation.request.length) orelse return refused;
answer.set(.{ .actual_length = transferred });
return 0;
}
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU
/// domain, so the controller may DMA to the physical addresses inside that buffer. The
/// binding holds its own kernel reference, so this never claims the arriving handle —
/// the turn's `defer` in the harness is the close, on the failure paths as well as this
/// one.
fn onDmaAttach(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const handle = invocation.capability orelse return -envelope.EPROTO;
return if (device.dmaBind(controller_id, handle)) 0 else refused;
}
/// A timer tick or an MSI landed: drain the event ring, reconcile ports, and fan out.
@@ -587,6 +693,12 @@ fn handleBulk(message: []const u8, reply: []u8) usize {
/// arriving after the drain takes IP 0→1 and fires a fresh edge instead of being
/// swallowed until the reconcile tick.
fn onNotification(badge: u64) void {
if (badge & ipc.notify_exit_bit != 0) {
// A class driver died: release the devices it held, so its successor can
// open them and no report is aimed at an endpoint that is gone.
releaseOpensOf(@intCast(badge & ~(ipc.notify_badge_bit | ipc.notify_exit_bit)));
return;
}
if (badge & ipc.notify_timer_bit != 0) {
serviceController();
_ = time.timerOnce(service_endpoint, timerInterval());
@@ -651,14 +763,18 @@ fn serviceController() void {
if (serviced >= 32) break;
}
while (engine.takeReport()) |report| {
var message = usb_transfer_protocol.InterruptReport{
.device_token = report.device_token,
.endpoint_address = report.endpoint_address,
.length = @intCast(@min(report.length, usb_transfer_protocol.max_report_data)),
};
// One `interrupt_report` event packet: the device token in the folded
// header, the report after it. A device that produced more than the
// push floor admits has its report truncated here, never split.
const n = @min(report.length, usb_transfer_protocol.max_report_data);
@memcpy(message.data[0..n], report.data[0..n]);
_ = ipc.send(report.report_endpoint, std.mem.asBytes(&message));
var payload = usb_transfer_protocol.InterruptReport{
.endpoint_address = report.endpoint_address,
.length = @intCast(n),
};
@memcpy(payload.data[0..n], report.data[0..n]);
var packet: [envelope.post_maximum]u8 = undefined;
const framed = usb_transfer_protocol.Protocol.encodeEvent(.interrupt_report, report.device_token, payload, &packet) orelse continue;
_ = ipc.send(report.report_endpoint, framed);
}
}
}
@@ -673,7 +789,8 @@ pub fn main(init: process.Init) void {
return;
};
service.run(usb_transfer_protocol.message_maximum, .{
.service = .usb_bus,
// No `.service`: the contract is bound inside `initialise`, where losing
// it to another controller's driver is survivable rather than fatal.
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,
@@ -1498,6 +1498,24 @@ pub const Controller = struct {
return true;
}
/// Stop reporting for `device_token`: deactivate every subscription tagged
/// with it, so no further report is queued and the slot can be reused. Called
/// when the class driver that owned the token dies — its endpoint handle is
/// closed with it, and a queued report would then be aimed at a handle number
/// the bus driver has since given to something else. In-process hub
/// subscriptions carry no token and are never touched.
///
/// One completion may already be in flight; it finds no active subscription
/// and is dropped as a foreign transfer event, which is exactly what it is.
pub fn releaseSubscriptions(self: *Controller, device_token: u64) void {
for (&self.subscriptions) |*subscription| {
if (!subscription.active or subscription.hub != null) continue;
if (subscription.device_token != device_token) continue;
subscription.active = false;
subscription.report_endpoint = 0;
}
}
// Arm (or re-arm) a subscription's endpoint with a Normal TRB pointing at its
// report buffer, and ring the endpoint's doorbell so the controller polls it.
fn armInterrupt(self: *Controller, subscription: *Subscription) void {
+2 -2
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 = &.{
"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);
+55 -46
View File
@@ -15,6 +15,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -24,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;
@@ -475,66 +484,66 @@ fn presentFull() bool {
fn announce() void {
var tries: u32 = 0;
const display = while (tries < 50) : (tries += 1) {
if (ipc.lookup(.display)) |h| break h;
if (channel.openEndpoint("display")) |h| break h;
time.sleepMillis(20);
} else {
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, capability: ?ipc.Handle) usize {
_ = sender;
_ = capability;
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 {
@@ -546,8 +555,8 @@ pub fn main(init: process.Init) void {
std.log.info("malformed device id '{s}'", .{argument});
return;
};
service.run(256, .{
.service = .scanout,
service.run(scanout_protocol.message_maximum, .{
.service = "scanout",
.init = initialise,
.on_message = onMessage,
});
+79 -36
View File
@@ -40,16 +40,13 @@ const Task = scheduler.Task;
pub const MESSAGE_MAXIMUM: usize = 256;
pub const maximum_handles = scheduler.ipc_maximum_handles;
// The name registry is indexed directly by ServiceId, so this must exceed the
// largest id (currently fat = 8). Sized with headroom for new services.
pub const maximum_services = 16;
/// Errno-style failures, returned as `-value` in the system_call result register.
pub const EBADF: i64 = 1; // bad handle
pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM
pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half
pub const ENOENT: i64 = 4; // no such registered service
pub const ENOSPC: i64 = 5; // handle table or registry full
pub const ENOENT: i64 = 4; // no such name
pub const ENOSPC: i64 = 5; // handle table full
pub const ENOMEM: i64 = 6; // out of memory
pub const EPEER: i64 = 7; // peer died before replying (its process exited or was killed)
pub const ESRCH: i64 = 8; // no such process (process_kill of an unknown/dead id)
@@ -90,12 +87,21 @@ const PostSlot = struct {
const user_half_end: u64 = user_memory.user_half_end;
/// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle
/// (per process) and/or by a registry slot, counted by `refcount`.
/// (per process) and by whoever a capability was passed to, counted by `refcount`.
pub const Endpoint = struct {
refcount: u32 = 1,
/// Next in the list of every live endpoint. Endpoints are otherwise reachable
/// only through the handle tables that name them, and the death path has to
/// find a dying task's endpoints without one — see `live_endpoints`.
next_live: ?*Endpoint = null,
// The task that created it. When that task dies, the endpoint is marked `dead` so a caller
// gets -EPEER instead of blocking forever on a service that will never reply again (V6).
owner: u32 = 0,
// The *process* that created it — `owner`'s leader, snapshotted at creation so the
// answer survives the creating thread. `owner` alone cannot answer "is this mine?"
// for a threaded service, and the question has to be answerable after that thread is
// gone; see `ownedBy`.
owner_leader: u32 = 0,
dead: bool = false,
// Callers blocked in `call`, awaiting receive, in FIFO order (threaded via
// Task.next; each such task is .blocked and in no scheduler queue).
@@ -115,29 +121,78 @@ pub const Endpoint = struct {
post_tail: u16 = 0,
};
/// Every live endpoint, singly linked through `next_live`. The list exists for
/// exactly one purpose: the death path must mark a dying task's endpoints dead,
/// and a handle table only answers the other question (which endpoints does this
/// task *hold*). Mutated under the big kernel lock, like every other IPC global.
var live_endpoints: ?*Endpoint = null;
pub fn createIpcEndpoint() ?*Endpoint {
const creator = scheduler.current();
const endpoint = heap.allocator().create(Endpoint) catch return null;
endpoint.* = .{ .owner = scheduler.currentId() };
endpoint.* = .{ .owner = creator.id, .owner_leader = creator.leader, .next_live = live_endpoints };
live_endpoints = endpoint;
return endpoint;
}
/// A task is dying: kill the endpoints it registered as services. Mark each `dead` (so a later
/// `call` returns -EPEER rather than blocking on a reply that will never come), wake anyone
/// already parked sending to it with that error, and vacate its registry slot. Only *registered*
/// endpoints are reachable from here; unregistered ones drop with the task's handle table. The
/// caller holds the big kernel lock (this runs on the death path). See docs/display-v2.md (V6).
/// Whether `t` may have the kernel post **notifications** — signals, timer
/// landings, exit notices, interrupts — into `endpoint`: whether the endpoint is
/// its process's own.
///
/// Holding a *handle* to an endpoint is not ownership of it. `fs_resolve`
/// installs a mounted backend's capability in any caller's table
/// (`installHandleDeduped`), and any capability may be passed along a call, so a
/// sendable handle means only "you may talk to this". A kernel notification is
/// different in kind: it makes the kernel speak *into* someone else's mailbox
/// with a badge that receiver cannot distinguish from one it asked for — a
/// genuine signal badge, a genuine timer landing. That is how a forged
/// `terminate` reached PID 1's shutdown path: the attacker aimed **its own**
/// signal delivery at init's endpoint with `signal_bind` and then signalled
/// itself, and every bit the kernel stamped was authentic. Refusing the *bind*
/// is the only place the distinction still exists.
///
/// Threads: ownership is the **process's**, not the task's, so any thread may
/// bind an endpoint a sibling created — the same normalization `process_signal`
/// and `process_kill` perform when they resolve a member to its leader. The
/// creating task's own id is honoured too, which is what keeps kernel tasks
/// (leader 0) from being treated as one process.
pub fn ownedBy(endpoint: *const Endpoint, t: *const Task) bool {
if (endpoint.owner == t.id) return true;
return t.leader != 0 and endpoint.owner_leader == t.leader;
}
/// Unlink a freed endpoint from the live list. O(n) in the number of live
/// endpoints, which is tens.
fn forgetEndpoint(endpoint: *Endpoint) void {
var link = &live_endpoints;
while (link.*) |current| {
if (current == endpoint) {
link.* = current.next_live;
return;
}
link = &current.next_live;
}
}
/// A task is dying: kill every endpoint it created. Mark each `dead` (so a later
/// `call` returns -EPEER rather than blocking on a reply that will never come) and wake
/// anyone already parked sending to it with that error. This is what makes a provider's
/// death visible to the clients holding its capability — the naming layer's restart
/// story (a client re-resolves on -EPEER) rests on it, as does the VFS router's lazy
/// unmount of a backend that died. The endpoint object itself lives until the last
/// handle naming it drops. The caller holds the big kernel lock (this runs on the death
/// path). See docs/display-v2.md (V6).
pub fn killOwnedEndpointsLocked(task_id: u32) void {
for (&registry) |*slot| {
const endpoint = slot.* orelse continue;
if (endpoint.owner != task_id) continue;
var current = live_endpoints;
while (current) |endpoint| {
current = endpoint.next_live;
if (endpoint.owner != task_id or endpoint.dead) continue;
endpoint.dead = true;
while (dequeueSender(endpoint)) |sender| {
sender.ipc_status = -EPEER;
sender.ipc_received_cap = abi.no_cap;
scheduler.readyLocked(sender);
}
slot.* = null;
dropRef(endpoint);
}
}
@@ -147,6 +202,7 @@ pub fn dropRef(endpoint: *Endpoint) void {
if (endpoint.refcount > 1) {
endpoint.refcount -= 1;
} else {
forgetEndpoint(endpoint);
heap.allocator().destroy(endpoint);
}
}
@@ -633,22 +689,9 @@ fn dropEntry(entry: scheduler.HandleObject) void {
}
}
var registry: [maximum_services]?*Endpoint = .{null} ** maximum_services;
/// Publish `endpoint` under well-known `id` (takes a reference). Returns 0 or -errno.
pub fn register(id: u32, endpoint: *Endpoint) i64 {
if (id >= maximum_services) return -ENOENT;
if (registry[id]) |old| dropRef(old);
endpoint.refcount += 1;
registry[id] = endpoint;
return 0;
}
/// Find the endpoint published under `id`, taking a reference for the caller to
/// install in its handle table. Null if nothing is registered there.
pub fn lookup(id: u32) ?*Endpoint {
if (id >= maximum_services) return null;
const endpoint = registry[id] orelse return null;
endpoint.refcount += 1;
return endpoint;
}
// The flat `ServiceId` registry lived here — a 16-slot table any process could
// write, indexed by a compile-time enum. Naming is user-space's job now: init
// serves `/protocol` and decides who may claim a name
// (docs/os-development/protocol-namespace.md). The kernel keeps only what is
// genuinely kernel work — moving capabilities and telling clients their provider
// died (`killOwnedEndpointsLocked`).
+2 -2
View File
@@ -47,8 +47,8 @@ pub const maximum_gsi = 24;
var bound: [maximum_gsi]?*ipc_sync.Endpoint = .{null} ** maximum_gsi;
/// Task that owns each binding. Teardown is keyed on *this*, not on the endpoint
/// pointer: an endpoint can be shared between processes (ipc_register/ipc_lookup hand
/// out extra references), so "every GSI pointing at this endpoint" is not the same
/// pointer: an endpoint can be shared between processes (a capability passed in a message
/// hands out extra references), so "every GSI pointing at this endpoint" is not the same
/// set as "every GSI this process bound", and releasing the former on exit would mask
/// a live sibling's device line.
var bound_owner: [maximum_gsi]u32 = .{0} ** maximum_gsi;
+81 -46
View File
@@ -229,8 +229,6 @@ fn system_call(state: *architecture.CpuState) void {
.mmap => systemMmap(state),
.munmap => systemMunmap(state),
.create_ipc_endpoint => systemCreateIpcEndpoint(state),
.ipc_register => systemIpcRegister(state),
.ipc_lookup => systemIpcLookup(state),
.ipc_call => systemIpcCall(state),
.ipc_reply_wait => systemIpcReplyWait(state),
.ipc_send => systemIpcSend(state),
@@ -320,36 +318,6 @@ fn systemCreateIpcEndpoint(state: *architecture.CpuState) void {
architecture.setSystemCallResult(state, @intCast(h));
}
/// ipc_register(service_id, handle): publish the caller's endpoint under a
/// well-known id so other processes can find it.
fn systemIpcRegister(state: *architecture.CpuState) void {
// Under the big kernel lock: mutates the global service registry and endpoint
// refcounts, which threads of the same (or another) process can race.
const flags = sync.enter();
defer sync.leave(flags);
const id: u32 = @truncate(architecture.systemCallArg(state, 0));
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF);
architecture.setSystemCallResult(state, @bitCast(ipc.register(id, endpoint)));
}
/// ipc_lookup(service_id) -> handle: find a published endpoint and install a
/// handle to it in the caller.
fn systemIpcLookup(state: *architecture.CpuState) void {
// Under the big kernel lock: reads the global registry, takes an endpoint reference,
// and installs a handle — all racy against concurrent threads (this is the path the
// display's mouse-listener thread takes to reach the compositor endpoint).
const flags = sync.enter();
defer sync.leave(flags);
const id: u32 = @truncate(architecture.systemCallArg(state, 0));
const endpoint = ipc.lookup(id) orelse return failErr(state, ipc.ENOENT);
const h = ipc.installHandle(scheduler.current(), endpoint);
if (h < 0) {
ipc.dropRef(endpoint);
return failErr(state, ipc.ENOSPC);
}
architecture.setSystemCallResult(state, @intCast(h));
}
/// ipc_call(handle, message_ptr, message_len, reply_ptr, reply_cap) -> reply_len.
/// Blocks until the server replies; the trap frame lives on this task's kernel
/// stack, so it survives the block and receives the result on resume.
@@ -364,7 +332,14 @@ fn systemIpcCall(state: *architecture.CpuState) void {
/// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len,
/// with the sender's badge in the secondary result register (rdx).
fn systemIpcReplyWait(state: *architecture.CpuState) void {
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
const t = scheduler.current();
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
// Receiving is the owner's privilege, the same rule the notification binders
// enforce: a sendable handle means only "you may talk to this". Anything
// else and a mount's backend endpoint — which `fs_resolve` installs in every
// caller's table — would let a stranger dequeue the requests meant for the
// server, taking the capabilities they carry and answering in its name.
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
var badge: u64 = 0;
var received_cap: u64 = abi.no_cap;
const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap);
@@ -989,10 +964,17 @@ fn systemSpawn(state: *architecture.CpuState) void {
if (len == 0 or len > scheduler.maximum_task_name or ptr >= user_half_end or ptr + len > user_half_end) return fail(state);
if (arguments_len > maximum_argument_bytes) return fail(state);
if (arguments_len != 0 and (arguments_ptr >= user_half_end or arguments_ptr + arguments_len > user_half_end)) return fail(state);
// The exit endpoint is a notification binding like signal_bind's and
// timer_bind's, so it obeys the same rule: the caller's own mailbox, never a
// stranger's. Otherwise any process could have the kernel post child-exit
// badges into PID 1 by spawning throwaway children against init's endpoint.
const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
null
else
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
else block: {
const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
break :block endpoint;
};
const image = ramdisk_image orelse return fail(state);
const rd = initial_ramdisk.Reader.init(image) orelse return fail(state);
@@ -1040,11 +1022,15 @@ fn systemThreadSpawn(state: *architecture.CpuState) void {
if (t.address_space == 0) return fail(state); // kernel tasks own no address space to share
if (entry == 0 or entry >= user_half_end) return fail(state);
if (stack_top == 0 or stack_top > user_half_end) return fail(state);
// The endpoint the thread notifies on exit (how join waits), or none.
// The endpoint the thread notifies on exit (how join waits), or none — the
// caller's own, like every other notification binding.
const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
null
else
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
else block: {
const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
break :block endpoint;
};
const tid = spawnThreadSupervised(t.address_space, entry, stack_top, arg, t.priority, t.id, exit_endpoint, t.leader);
if (tid == -ipc.ESRCH) return failErr(state, ipc.ESRCH); // dying group admits no member
if (tid < 0) return fail(state);
@@ -1534,37 +1520,72 @@ pub fn exitReasonOf(caller_id: u32, target_id: u32) i64 {
/// subscriptions — that must release what a dead client held and cannot learn it
/// any other way (a client that simply never calls again looks like silence).
/// Bounded like every kernel table; each entry holds its own endpoint reference.
const exit_subscriber_capacity = 8;
///
/// Sixteen, not eight: a subscription is now what *every* provider with
/// per-client state uses to release it — the FAT server's open files, the input,
/// power and device-manager subscriber tables (the service harness subscribes for
/// them), the compositor's layers, and each USB controller driver's device tokens.
/// A single boot already fields six, and a machine with several xHCI controllers
/// fields one per controller, so the old ceiling was within two of a service
/// silently losing its sweep.
const exit_subscriber_capacity = 16;
const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 };
var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity;
/// process_subscribe(endpoint): subscribe the caller's endpoint to published exit
/// events. Ungated, like process_enumerate — what is running (and dying) is not a
/// secret between cooperating processes. -ENOSPC when the table is full.
/// process_subscribe(endpoint): subscribe the **caller's own** endpoint to
/// published exit events. *Which* deaths one may hear of is ungated, like
/// process_enumerate — what is running (and dying) is not a secret between
/// cooperating processes. *Whose mailbox* they land in is not: the endpoint must
/// be the caller's (`ipc.ownedBy`), or any process could aim the firehose at a
/// stranger — filling PID 1's mailbox with exit notices it reads as its own
/// children's, and spending the eight-slot table so the services that need
/// deaths (the VFS's handle sweep) cannot subscribe at all. -EPERM otherwise,
/// -ENOSPC when the table is full.
fn systemProcessSubscribe(state: *architecture.CpuState) void {
const t = scheduler.current();
if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
const result = subscribeExits(endpoint, t.id);
if (result < 0) return failErr(state, @intCast(-result));
architecture.setSystemCallResult(state, 0);
}
/// Take a slot in the published-exit table for `endpoint`, owned by task `owner`.
/// The body of `process_subscribe` minus the authorization, so a kernel test can
/// exercise the fan-out (several subscribers, one death, every one notified) that
/// every provider's release-what-the-dead-client-held sweep is built on. Returns
/// 0, or -ENOSPC.
pub fn subscribeExits(endpoint: *ipc.Endpoint, owner: u32) i64 {
const flags = sync.enter();
defer sync.leave(flags);
for (&exit_subscribers) |*slot| {
if (slot.* == null) {
endpoint.refcount += 1; // the slot's own reference, dropped on unsubscribe-by-death
slot.* = .{ .endpoint = endpoint, .owner = t.id };
return architecture.setSystemCallResult(state, 0);
slot.* = .{ .endpoint = endpoint, .owner = owner };
return 0;
}
}
failErr(state, ipc.ENOSPC);
return -ipc.ENOSPC;
}
/// signal_bind(endpoint): nominate where this process's signals arrive — the
/// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a
/// binding drops the old reference; signals that pended while unbound are
/// delivered immediately on bind, coalesced into one notification.
///
/// The endpoint must be the caller's own (`ipc.ownedBy`), or `signal_bind`
/// becomes a signal *forgery* primitive: `process_signal` is deliberately loose
/// about the target (a task may always signal itself) because the delivery point
/// was assumed to be the target's own mailbox. Aim it elsewhere and a stranger
/// signalling itself makes the kernel stamp a genuine `terminate` badge into
/// somebody else's queue — which is a shutdown request PID 1 has no way to
/// disbelieve.
fn systemSignalBind(state: *architecture.CpuState) void {
const t = scheduler.current();
if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
const flags = sync.enter();
defer sync.leave(flags);
if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw)));
@@ -1633,11 +1654,19 @@ fn timerSweepLocked() void {
}
}
/// timer_bind(endpoint, ms): arm a one-shot timer. -ENOSPC when the table is full.
/// timer_bind(endpoint, ms): arm a one-shot timer on an endpoint of the caller's
/// own (`ipc.ownedBy`; -EPERM otherwise). A timer landing carries no identity —
/// that is the whole reason a service may keep exactly one in flight — so a
/// timer armed on someone else's endpoint is indistinguishable from one they
/// armed themselves, and a loop that re-arms on every landing (init's heartbeat)
/// multiplies: N forged timers leave N+1 self-perpetuating beats. The
/// sixteen-slot table is a shared resource on top of that. -ENOSPC when it is
/// full.
fn systemTimerBind(state: *architecture.CpuState) void {
const t = scheduler.current();
if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
const ms = architecture.systemCallArg(state, 1);
const flags = sync.enter();
defer sync.leave(flags);
@@ -1677,12 +1706,17 @@ fn ownedGsi(t: *scheduler.Task, device_id: u64, resource_index: u64) ?u32 {
/// irq_bind(device_id, resource_index, endpoint) -> 0/-1: deliver that device's IRQ to the
/// endpoint as an asynchronous IPC notification. The driver then blocks in
/// IPC_ReplyWait and is woken by the ISR; see system/kernel/irq.zig for the cycle.
/// Two gates, both necessary: the device must be *claimed* by the caller
/// (`ownedGsi`), and the endpoint must be the caller's own (`ipc.ownedBy`) — a
/// claim entitles a driver to its own interrupts, not to post them into a
/// stranger's mailbox.
fn systemIrqBind(state: *architecture.CpuState) void {
const t = scheduler.current();
if (t.address_space == 0) return fail(state);
const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse
return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 2)) orelse return fail(state);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
const flags = sync.enter();
defer sync.leave(flags);
@@ -1703,6 +1737,7 @@ fn systemMsiBind(state: *architecture.CpuState) void {
const owner = devices_broker.ownerOf(device_id) orelse return fail(state);
if (owner != t.id) return fail(state); // not claimed by this process
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF);
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); // interrupts land in your own mailbox
const flags = sync.enter();
defer sync.leave(flags);
+297 -17
View File
@@ -246,6 +246,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
containmentTest();
} else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) {
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 {
@@ -2209,7 +2215,7 @@ fn processKillTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "spinner" }, me, endpoint) catch 0;
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "spinner" }, me, endpoint) catch 0;
break;
}
check("process-test spawned as the supervised spinner victim", spinner != 0);
@@ -2242,9 +2248,67 @@ fn processKillTest(boot_information: *const BootInformation) void {
}
check("neither victim is listed after its kill", !still_listed);
check("a killed id stays dead (-ESRCH on a second kill)", process.killProcess(me, sleeper) == -ipcsync.ESRCH);
publishedExitChecks(rd, me);
result();
}
/// The mechanism every provider's release-what-a-dead-client-held sweep is built
/// on (docs/process-lifecycle.md, "Who learns of a death"): a **published** exit,
/// fanned out to every subscriber rather than only to the supervisor. The service
/// harness's subscriber sweep, the FAT server's open files, the compositor's
/// layers and the xHCI driver's device tokens all release on exactly this, and
/// several of them are subscribed at once in a normal boot — so what is checked
/// here is the fan-out: three independent subscribers, one death, three
/// notifications carrying the same badge, none of them the supervisor's.
///
/// Run at the end of the process-kill case, because a subscription is for every
/// death from then on and the checks above spawn victims of their own.
fn publishedExitChecks(rd: initial_ramdisk.Reader, me: u32) void {
var subscribers: [3]*ipcsync.Endpoint = undefined;
var subscribed: usize = 0;
while (subscribed < subscribers.len) : (subscribed += 1) {
subscribers[subscribed] = ipcsync.createIpcEndpoint() orelse break;
if (process.subscribeExits(subscribers[subscribed], me) != 0) break;
}
check("three endpoints subscribed to published exits", subscribed == subscribers.len);
if (subscribed != subscribers.len) return;
// A supervised child so the supervisor notification remains distinguishable:
// it lands on `endpoint`, the published ones on the three above.
const supervisor_endpoint = ipcsync.createIpcEndpoint() orelse {
check("supervisor endpoint allocated", false);
return;
};
var child: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "args-echo")) continue;
child = process.spawnProcessSupervised(item.blob, 4, &.{ "args-echo", "published-exit" }, me, supervisor_endpoint) catch 0;
break;
}
check("a clean-exit child spawned for the published exit", child != 0);
if (child == 0) return;
var badge: u64 = 0;
var received_cap: u64 = 0;
_ = ipcsync.replyWait(supervisor_endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the supervisor heard the child end", badge == abi.notify_badge_bit | abi.notify_exit_bit | child);
// The publication happens in the same locked section as the supervisor's
// notification and before it, so all three are already queued: a subscriber
// that had not heard would block here and time the harness out rather than
// pass vacuously.
var heard: usize = 0;
for (subscribers) |subscriber| {
badge = 0;
_ = ipcsync.replyWait(subscriber, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
if (badge == abi.notify_badge_bit | abi.notify_exit_bit | child) heard += 1;
}
check("every subscriber heard the same death, not just the supervisor", heard == subscribers.len);
}
/// M17.1: a dead process's device claims are released by the reap, so a restarted
/// driver can claim its hardware again (docs/process-lifecycle.md iron rule 1).
/// First the broker release in isolation — two owners, one released, the other's
@@ -2395,13 +2459,14 @@ fn signalsTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image); // the parent system_spawns its children by name
_ = spawnRegistry(rd); // the service child binds /protocol/test/process
process.write_count = 0;
var runner: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
runner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "signal-run" }, scheduler.currentId(), null) catch 0;
runner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "signal-run" }, scheduler.currentId(), null) catch 0;
break;
}
check("signal-run parent spawned", runner != 0);
@@ -2443,13 +2508,14 @@ fn driverRestartTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image); // the manager system_spawns drivers by name
_ = spawnRegistry(rd); // the drivers bind their contracts
process.write_count = 0;
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-restart" }, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-restart mode", manager != 0);
@@ -2482,12 +2548,13 @@ fn usbReportTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the xhci driver binds /protocol/usb-transfer
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-usb-restart mode", manager != 0);
@@ -2514,12 +2581,13 @@ fn deviceListTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the fixture opens /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-usb-restart mode", manager != 0);
@@ -2548,13 +2616,14 @@ fn pciCapsTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
// Plain mode — no restart drill, whose kill would race the fixture's claim.
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned", manager != 0);
@@ -2582,12 +2651,13 @@ fn iommuFaultTest(boot_information: *const BootInformation) void {
check("IOMMU enabled for the enforcement test", iommu.enabled());
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned", manager != 0);
@@ -2623,12 +2693,13 @@ fn pciScanTest(boot_information: *const BootInformation) void {
check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0);
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-pci-restart" }, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-pci-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned (test-pci-restart mode)", manager != 0);
@@ -2726,6 +2797,10 @@ fn fatMountTest(boot_information: *const BootInformation) void {
const init_ok = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned (boots the tree, incl. the fat server)", init_ok);
check("fat-test client spawned", spawnNamed(rd, "fat-test"));
// The badge-scoping probe rides the same boot: it needs the fat server for a
// node id and the compositor for a layer id, and init starts both. It spawns
// its own second process — the intruder — with the ids it holds (P4c).
check("badge-scope-test owner spawned", spawnNamed(rd, "badge-scope-test"));
result();
}
@@ -2776,12 +2851,13 @@ fn acpiReportTest(boot_information: *const BootInformation) void {
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager and the acpi service bind theirs
var spawned = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
_ = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
_ = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
spawned = true;
break;
}
@@ -2819,7 +2895,7 @@ fn acpiParseTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "discovery")) continue;
_ = process.spawnProcessSupervised(item.blob, 4, &.{ "discovery", "1" }, scheduler.currentId(), null) catch 0;
_ = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "1" }, scheduler.currentId(), null) catch 0;
spawned = true;
break;
}
@@ -2854,7 +2930,7 @@ fn supervisionTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "process-test", "run" })) true else |_| false;
started = if (process.spawnProcess(item.blob, 4, &.{ item.name, "run" })) true else |_| false;
break;
}
check("process-test spawned as the user-space supervisor", started);
@@ -2996,6 +3072,9 @@ fn inputTest(boot_information: *const BootInformation) void {
process.write_count = 0;
process.write_from_user = false;
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the input service binds /protocol/input
_ = spawnNamed(rd, "input"); // the fan-out service
_ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events
_ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker
@@ -3037,6 +3116,10 @@ fn displayServiceTest(boot_information: *const BootInformation) void {
return;
};
// init (the registry) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the compositor binds /protocol/display
// Spawn the compositor and hand it the core. Its own serial heartbeats — `display:
// online WxH` and `display: presented frame 0` — are what the harness matches (it
// reads serial directly, like the fault cases). We don't poll for them in-kernel: a
@@ -3073,6 +3156,9 @@ fn displayCursorTest(boot_information: *const BootInformation) void {
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // input and display bind theirs
if (!spawnNamed(rd, "input")) {
log("display-cursor: could not spawn the input service\n", .{});
result();
@@ -3113,6 +3199,9 @@ fn displayDemoTest(boot_information: *const BootInformation) void {
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the compositor binds /protocol/display
if (!spawnNamed(rd, "display")) {
log("display-demo: could not spawn the display service\n", .{});
result();
@@ -3148,6 +3237,9 @@ fn sharedMemoryTest(boot_information: *const BootInformation) void {
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the server binds /protocol/test/shared-memory
if (!spawnNamed(rd, "shared-memory-server")) {
log("shared-memory: could not spawn shared-memory-server\n", .{});
result();
@@ -3185,12 +3277,13 @@ fn virtioGpuTest(boot_information: *const BootInformation) void {
// from the kernel device tree, spawns pci-bus, and matches the virtio-gpu class triple to
// spawn our driver with the function's device id as argv[1].
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the driver binds /protocol/scanout
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
@@ -3226,12 +3319,13 @@ fn displayNativeTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display, the manager, and the driver bind theirs
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
@@ -3270,12 +3364,13 @@ fn displayReattachTest(boot_information: *const BootInformation) void {
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display and the restarted driver bind theirs
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-scanout-restart" }, scheduler.currentId(), null) catch 0;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-scanout-restart" }, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
@@ -3625,6 +3720,21 @@ fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []c
result();
}
/// Bring up the protocol namespace for a scenario that spawns its providers
/// itself. `/protocol` is served by init, PID 1 — but a scenario case wants the
/// naming layer without init's whole service list underneath it, so init is
/// started in its `registry` role: it mounts `/protocol`, reads the grants, and
/// spawns nothing (docs/os-development/protocol-namespace.md; the plan's
/// decision 9). Providers retry their bind, so racing the mount is survivable —
/// but calling this first makes the race rare.
///
/// The caller must have published the initial ramdisk already
/// (`process.setInitialRamdisk`): init reads its manifests out of it, and every
/// `/protocol` resolve goes through the same kernel VFS.
fn spawnRegistry(rd: initial_ramdisk.Reader) bool {
return spawnNamedWithArg(rd, "init", "registry");
}
fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
@@ -3745,6 +3855,175 @@ fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescr
/// match `pci-bus`, and spawn it (with the bridge id as its argument) — and the spawned
/// pci-bus must reach its own live marker. It uses no special privilege — the same
/// `device_enumerate` any process could call.
/// P2 — the registrar (docs/os-development/protocol-namespace.md). Bring up
/// `/protocol` (init in its registry role) and hand the fixture the core: it
/// asserts that an ungranted bind is refused, that the kernel's reserved prefix
/// holds, that a name a live provider holds cannot be taken, and that killing a
/// provider makes its channel fail while re-resolving the same name reaches the
/// restarted instance.
///
/// It doubles as the security case for PID 1's shared mailbox, since resolving
/// `/protocol` hands every process a sendable handle to it: a forged power
/// payload, a redirected terminate signal, a timer or exit subscription armed on
/// a foreign endpoint, and capability-carrying ping storms against both PID 1 and
/// a harness-run service. Those assertions kill the boot when they regress rather
/// than printing anything, which is the strongest form available here.
///
/// The fixture's `protocol-registry: ok` is the marker; each step also prints its
/// own line, which the harness's ordered regex reads.
fn protocolRegistryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-registry\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;
};
// The fixture spawns its own providers by name, so the ramdisk must be
// published; init then mounts /protocol over the same kernel VFS.
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
check("protocol-registry-test spawned", spawnNamedWithArg(rd, "protocol-registry-test", "run"));
const pass_marker = "protocol-registry: ok";
const fail_marker = "protocol-registry: 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 step of the registry contract failed", !saw_fail);
check("the fixture completed every registry assertion", saw_pass);
result();
}
/// P3 — restriction stage one (docs/os-development/protocol-namespace.md). The
/// registrar now checks `open` against `/system/configuration/protocol.csv`, and
/// a caller with no grant is told exactly what a caller asking for a name nobody
/// bound is told.
///
/// The scenario is the assertion's scaffolding: `/protocol` (init in its registry
/// role), the **input service** — which binds a real contract the fixture is
/// deliberately not granted — and the fixture. Without a live provider on the
/// forbidden name, "refused" and "not bound yet" would be the same observation
/// and the case would prove nothing; the fixture reads `/protocol`'s own listing
/// to confirm the name is there before it asks for it.
///
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
/// line, which the harness's ordered regex reads.
fn protocolDeniedTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-denied\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 provider of the contract the fixture may NOT reach. It needs no
// hardware: it binds /protocol/input and waits for subscribers.
check("input service spawned", spawnNamed(rd, "input"));
check("protocol-denied-test spawned", spawnNamedWithArg(rd, "protocol-denied-test", "run"));
const pass_marker = "protocol-denied: ok";
const fail_marker = "protocol-denied: 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 step of the restriction contract failed", !saw_fail);
check("the fixture completed every restriction assertion", saw_pass);
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) {
@@ -3764,6 +4043,7 @@ fn deviceManagerTest(boot_information: *const BootInformation) void {
// all, it's because the manager discovered the PCI host bridge, matched, and
// spawned it.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
process.write_count = 0;
process.write_from_user = false;
@@ -3845,9 +4125,9 @@ fn hpetDeviceId() ?u64 {
/// 2. After `releaseOwner` for the binding's owner, that same entry is masked again.
///
/// And one property that can only be checked from kernel state: a *different* owner's
/// binding on the same endpoint survives. Endpoints are shared (ipc_register hands out
/// references), so teardown keyed on the endpoint pointer rather than the owning task
/// would mask a live sibling driver's device line.
/// binding on the same endpoint survives. Endpoints are shared (a capability passed in a
/// message hands out extra references), so teardown keyed on the endpoint pointer rather
/// than the owning task would mask a live sibling driver's device line.
fn irqFreeTest() void {
log("DANOS-TEST-BEGIN: irqfree\n", .{});
+33
View File
@@ -168,6 +168,11 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re
var slot: ?*Mount = null;
for (&mounts) |*m| {
if (m.used and std.mem.eql(u8, m.prefixSlice(), prefix)) {
// ...except the protocol namespace. Remount-replace is how a
// restarted FAT retakes /volumes/usb; letting it retake /protocol
// would hand the whole naming layer to whoever asked second.
// First mount wins, and init (PID 1) is always first.
if (std.mem.eql(u8, prefix, protocol_root)) return;
if (m.backend) |old| ipc.dropRef(old);
slot = m;
break;
@@ -346,6 +351,30 @@ fn isInitrdCarveOut(prefix: []const u8) bool {
return false;
}
/// The protocol namespace's root — a reserved prefix, like the initrd trees.
/// Init (PID 1) mounts the registry here once at boot and the prefix then
/// refuses everything: a second mount at it, any mount *under* it (which would
/// shadow one contract), and its unmount. That is the whole kernel-side residue
/// of the naming layer — the registrar authority itself never leaves init
/// (docs/os-development/protocol-namespace.md).
const protocol_root = "/protocol";
fn protocolBound() bool {
for (&mounts) |*m| {
if (m.used and std.mem.eql(u8, m.prefixSlice(), protocol_root)) return true;
}
return false;
}
/// Whether mounting at `prefix` would touch the protocol namespace. Exactly
/// `/protocol` is allowed once — while nothing holds it; anything under it,
/// ever, is refused.
fn refusesProtocolMount(prefix: []const u8) bool {
const relative = underMount(prefix, protocol_root) orelse return false;
if (relative.len != 1) return true; // strictly under /protocol: never
return protocolBound(); // /protocol itself: first mount wins
}
/// Mount `backend` at `prefix` with an optional backend-side `rewrite` prefix.
/// The endpoint reference is taken by the caller (process.zig bumps it); refuses
/// shadowing or replacing the initrd trees (/system, /test) — except the two
@@ -353,6 +382,7 @@ fn isInitrdCarveOut(prefix: []const u8) bool {
pub fn mountBackend(prefix: []const u8, backend: *ipc.Endpoint, rewrite: []const u8) bool {
if (!isAbsolute(prefix) or prefix.len < 2 or prefix.len > maximum_prefix) return false;
if (rewrite.len > maximum_rewrite) return false;
if (refusesProtocolMount(prefix)) return false; // the registry's prefix is claimed once
for (&mounts) |*m| { // the initrd trees are not shadowable (carve-outs aside)
if (m.used and m.kind == .kernel_initrd and underMount(prefix, m.prefixSlice()) != null) {
if (!isInitrdCarveOut(prefix)) return false;
@@ -363,6 +393,9 @@ pub fn mountBackend(prefix: []const u8, backend: *ipc.Endpoint, rewrite: []const
}
pub fn unmount(prefix: []const u8) bool {
// Unmounting /protocol would delete the naming layer for everyone; nobody
// may, init included. The mount lasts the boot.
if (std.mem.eql(u8, prefix, protocol_root)) return false;
for (&mounts) |*m| {
if (m.used and m.kind == .backend and std.mem.eql(u8, m.prefixSlice(), prefix)) {
if (m.backend) |endpoint| ipc.dropRef(endpoint);
+90 -72
View File
@@ -12,6 +12,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -21,6 +22,7 @@ const logging = @import("logging");
const aml = @import("aml");
const acpi_ids = @import("acpi-ids");
const device_manager_protocol = @import("device-manager-protocol");
const envelope = @import("envelope");
const power_protocol = @import("power-protocol");
/// AML opcode/prefix bytes by name (`zero_opcode`, `byte_prefix`, …) — so the `_HID`
/// integer decode names the opcodes instead of bare 0x0A/0x0B/… (docs/coding-standards.md).
@@ -58,15 +60,14 @@ const pwrbtn_bit: u16 = 1 << 8;
const sci_en_bit: u32 = 1 << 0;
const slp_en: u32 = 1 << 13;
// The `.power` subscribers: endpoints handed over as capabilities, each
// receiving events as buffered messages. Dropped on a failed send. The
// subscriber's task id is kept too — a shutdown request is honored only from a
// subscriber (init subscribes; a stray process does not), the soft gate that
// stands in for "only the system supervisor may power off" without hardcoding
// a pid the kernel's idle tasks would have taken.
const maximum_subscribers = 8;
var subscribers: [maximum_subscribers]?ipc.Handle = .{null} ** maximum_subscribers;
var subscriber_tasks: [maximum_subscribers]u32 = .{0} ** maximum_subscribers;
/// The `.power` subscribers, kept by the service harness (P4c): endpoints handed
/// over as capabilities, each receiving events as buffered messages, each swept
/// when its task dies. The table remembers which task subscribed, which is what
/// the shutdown gate below asks — a shutdown request is honored only from a
/// subscriber (init subscribes; a stray process does not), the soft gate that
/// stands in for "only the system supervisor may power off" without hardcoding a
/// pid the kernel's idle tasks would have taken.
const Subscriptions = service.Subscribers(power_protocol.Protocol, void);
// Pass-1 registration record (see main): what pass 2 reports.
const Registered = struct { hid: [8]u8 = .{0} ** 8, hid_len: usize = 0, device_id: u64 = 0, resource_count: u64 = 0 };
@@ -189,14 +190,33 @@ pub fn main(init: process.Init) void {
readFadt(fadt);
s5_valid = readSleepS5(&persistent_namespace);
// Every name this service needs, resolved before it becomes a provider — see
// `manager_channel`. Best-effort, as it has always been: a standalone
// bring-up with no device manager still serves power.
manager_channel = channel.openEndpoint("device-manager");
service.run(power_protocol.message_maximum, .{
.service = .power,
.service = "power",
.init = onInit,
.on_message = onMessage,
.on_notification = onNotification,
.subscribers = Subscriptions.hooks,
});
}
/// The device manager's channel, opened **before** this service binds its own
/// contract — deliberately, and load-bearing.
///
/// init is the registrar, and init is also this service's one subscriber: the
/// moment `power` is bound, init calls us to subscribe. init has a single thread,
/// so while it is blocked in that call it cannot answer anyone — including us. If
/// we opened a name after binding, the two could cross: init blocked calling us,
/// us blocked asking init to resolve a name, neither ever replying. Resolving
/// everything we need first makes that impossible, because after the bind this
/// service only ever talks to the device manager (which never calls init) and
/// then parks in the harness loop, where init's subscribe lands.
var manager_channel: ?ipc.Handle = null;
// Static so the harness callbacks (which run after main's stack frame is gone)
// can reach the namespace and interpreter.
var persistent_namespace: aml.Namespace = undefined;
@@ -209,7 +229,7 @@ fn onInit(endpoint: ipc.Handle) bool {
registered_count = 0;
walkDevices(persistent_namespace.root, &global_interpreter);
const manager = ipc.lookup(.device_manager);
const manager = manager_channel;
var i: usize = 0;
while (i < registered_count) : (i += 1) {
const entry = registered[i];
@@ -222,10 +242,20 @@ fn onInit(endpoint: ipc.Handle) bool {
else
std.log.info("device {d} bus=acpi hid={s} ({d} resources)", .{ entry.device_id, hid, entry.resource_count });
if (manager) |h| {
var report = device_manager_protocol.ChildAdded{ .bus = @intFromEnum(device_manager_protocol.BusKind.acpi), .parent = node_id, .bus_address = entry.device_id, .identity = 0, .device_id = entry.device_id };
// The registered device id is the packet's target, so the body only
// says where on the firmware tree the node sits and what it is.
var report = device_manager_protocol.ChildAdded{
.bus = @intFromEnum(device_manager_protocol.BusKind.acpi),
.parent = node_id,
.bus_address = entry.device_id,
.identity = 0,
};
@memcpy(report.hid[0..entry.hid_len], entry.hid[0..entry.hid_len]);
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, std.mem.asBytes(&report), &reply) catch {};
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
if (device_manager_protocol.Protocol.encodeRequest(.child_added, entry.device_id, report, &.{}, &packet)) |framed| {
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, framed, &reply) catch {};
}
}
}
std.log.info("reported {d} device(s) to the manager", .{registered_count});
@@ -370,13 +400,21 @@ fn dispatchGpe(n: u32) void {
fn publishNotify(node: *aml.Node, code: u64) void {
// Map the notified device's _HID to a domain event where we recognize it.
// The kind IS the packet's verb, so the mapping picks which event to frame
// rather than which tag to put in a payload.
var hid: [8]u8 = .{0} ** 8;
if (readHid(node, &global_interpreter)) |h| hid = h;
const which: power_protocol.Event = if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) .battery else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) .ac else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) .lid else .notify;
var event = power_protocol.EventMessage{ .event = @intFromEnum(which), .code = @truncate(code) };
event.hid = hid;
const notice = power_protocol.Notice{ .code = @truncate(code), .hid = hid };
std.log.info("power: notify {s} code {d}", .{ hid[0..7], code });
publishEvent(std.mem.asBytes(&event));
if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) {
Subscriptions.publish(.battery, 0, notice);
} else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) {
Subscriptions.publish(.ac, 0, notice);
} else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) {
Subscriptions.publish(.lid, 0, notice);
} else {
Subscriptions.publish(.notify, 0, notice);
}
}
/// Two lowercase hex digits of `n` into `out[0..2]`.
@@ -387,23 +425,10 @@ fn writeHex2(out: []u8, n: u32) void {
}
fn publishButton() void {
const event = power_protocol.EventMessage{ .event = @intFromEnum(power_protocol.Event.power_button) };
publishEvent(std.mem.asBytes(&event));
}
fn publishEvent(bytes: []const u8) void {
for (&subscribers) |*slot| {
if (slot.*) |handle| {
if (!ipc.send(handle, bytes)) slot.* = null;
}
}
}
fn isSubscriber(task: u32) bool {
for (&subscribers, 0..) |*slot, si| {
if (slot.* != null and subscriber_tasks[si] == task) return true;
}
return false;
// The kind is the packet's operation, so the harness frames it once and pushes
// the same bytes to every subscriber. There is no class here: a power event
// goes to everyone who asked for power events.
Subscriptions.publish(.power_button, 0, .{});
}
/// Enter S5 (soft off): write SLP_TYP|SLP_EN to the PM1 control register(s).
@@ -425,46 +450,39 @@ fn enterS5() void {
// --- harness callbacks --------------------------------------------------------
fn onNotification(badge: u64) void {
// The only notification the service binds is the SCI (an IRQ badge).
_ = badge;
// Two kinds of notification reach this loop now. The SCI is the one this
// service binds; the published process exits are the harness's, which it has
// already used to sweep the subscriber table before calling here. Everything
// that is not a bare IRQ badge must therefore be ignored — treating a death
// as an interrupt would clear PM1 status the firmware never set.
if (badge & (ipc.notify_exit_bit | ipc.notify_timer_bit | ipc.notify_message_bit | ipc.notify_signal_bit) != 0) return;
onSci();
}
/// The `.power` protocol: subscribe (endpoint as the call's capability),
/// shutdown (PID 1 only). Device discovery uses a different endpoint (the
/// device manager's), so nothing here handles ChildAdded.
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
if (message.len < 1) return 0;
switch (message[0]) {
@intFromEnum(power_protocol.Operation.subscribe) => {
var status: i32 = -1;
if (capability) |handle| {
for (&subscribers, 0..) |*slot, si| {
if (slot.* == null) {
slot.* = handle;
subscriber_tasks[si] = sender;
status = 0;
break;
}
}
}
const r = power_protocol.Reply{ .status = status };
@memcpy(reply[0..@sizeOf(power_protocol.Reply)], std.mem.asBytes(&r));
return @sizeOf(power_protocol.Reply);
},
@intFromEnum(power_protocol.Operation.shutdown) => {
// Honored only from a power subscriber — init, which has already run
// the stop sequence over everything else. The power service is
// mechanism (write S5); deciding *when* to shut down and stopping
// the rest of the system first is init's policy.
const allowed = isSubscriber(sender);
const r = power_protocol.Reply{ .status = if (allowed) 0 else -1 };
@memcpy(reply[0..@sizeOf(power_protocol.Reply)], std.mem.asBytes(&r));
if (allowed) enterS5();
return @sizeOf(power_protocol.Reply);
},
else => return 0,
}
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// The power contract: the reserved `subscribe` (the subscriber's endpoint as
/// the call's capability, answered by the harness) and `shutdown` (subscribers
/// only). Device discovery uses a different endpoint — the device manager's — so
/// nothing here handles a tree report.
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply);
}
/// `subscribe` and `unsubscribe` are absent on purpose: the harness answers both.
const handlers = Subscriptions.Handlers{ .shutdown = onShutdown };
/// Honored only from a power subscriber — init, which has already run the stop
/// sequence over everything else. The power service is mechanism (write S5);
/// deciding *when* to shut down and stopping the rest of the system first is
/// init's policy. The badge is the whole gate: it is kernel-stamped, so nothing
/// in the packet can claim to be init. The subscriber table moved into the
/// harness; the question it answers has not changed.
fn onShutdown(_: void, invocation: Invocation(void), _: Answer(void)) isize {
if (!Subscriptions.has(invocation.sender)) return -envelope.EPERM;
enterS5();
return 0;
}
/// Depth-first walk: register + report each present device with a _HID, then
+2 -2
View File
@@ -14,8 +14,8 @@ pub fn build(b: *std.Build) void {
.name = "discovery",
.root_source_file = b.path("acpi.zig"),
.imports = &.{
"acpi-ids", "aml", "device-manager-protocol", "driver", "ipc", "logging", "memory",
"power-protocol", "process", "service", "time",
"acpi-ids", "aml", "channel", "device-manager-protocol", "driver", "envelope",
"ipc", "logging", "memory", "power-protocol", "process", "service", "time",
},
});
b.installArtifact(exe);
+2 -2
View File
@@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "device-manager",
.root_source_file = b.path("device-manager.zig"),
.imports = &.{
"device-manager-protocol", "device-registry", "driver", "file-system", "ipc",
"logging", "memory", "process", "service", "time",
"device-manager-protocol", "device-registry", "driver", "envelope", "file-system",
"ipc", "logging", "memory", "process", "service", "time",
},
});
b.installArtifact(exe);
+99 -109
View File
@@ -24,7 +24,17 @@ const time = @import("time");
const memory = @import("memory");
const logging = @import("logging");
const device_manager_protocol = @import("device-manager-protocol");
const envelope = @import("envelope");
const registry = @import("device-registry");
/// The generated device-manager dispatch, plus the subscriber machinery the
/// harness owns (P4c): the watcher table, the reserved `subscribe` verb, the
/// exit sweep, and the fan-out. One manager per system, so the handler context is
/// empty and the tables stay in this file's globals.
const Serve = service.Subscribers(device_manager_protocol.Protocol, void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
const fs = @import("file-system");
// --- the device registry ------------------------------------------------------
@@ -148,23 +158,6 @@ var test_scanout_killed = false;
var test_kill_pid: u32 = 0;
var test_kill_due_ns: u64 = 0;
/// The application subscribers (M18.3, the input-service pattern): endpoints
/// handed over as capabilities, each receiving every child add/remove as a
/// buffered message. A subscriber whose endpoint stops accepting (it died) is
/// dropped on the failed send.
const maximum_subscribers = 8;
var subscribers: [maximum_subscribers]?ipc.Handle = .{null} ** maximum_subscribers;
/// Publish one event (a ChildAdded or ChildRemoved struct, the same encoding
/// the bus drivers send) to every subscriber.
fn publishEvent(event: []const u8) void {
for (&subscribers) |*slot| {
if (slot.*) |handle| {
if (!ipc.send(handle, event)) slot.* = null; // dead subscriber
}
}
}
/// The manager's mirror of what bus drivers report (docs/device-manager.md "the
/// tree"): the children, keyed by (parent, bus address), each remembering which
/// driver instance reported it — that is what death-pruning sweeps by.
@@ -209,8 +202,7 @@ fn pruneChildrenOf(reporter: u32) void {
if (child.used and child.reporter == reporter) {
std.log.info("child removed (device {d} port {d})", .{ child.parent, child.bus_address });
child.used = false;
const event = device_manager_protocol.ChildRemoved{ .parent = child.parent, .bus_address = child.bus_address };
publishEvent(std.mem.asBytes(&event));
Serve.publish(.child_removed, 0, .{ .parent = child.parent, .bus_address = child.bus_address });
}
}
}
@@ -225,7 +217,11 @@ fn childCountOf(reporter: u32) u32 {
return n;
}
/// The driver entry a live process id belongs to. Zero is not a process id here:
/// it is what `onDriverExit` writes back to retire an id it has already acted on,
/// so a second notification for the same death matches nothing.
fn driverByProcess(process_id: u32) ?*Driver {
if (process_id == 0) return null;
for (&drivers) |*driver| {
if (driver.used and driver.process_id == process_id) return driver;
}
@@ -293,8 +289,17 @@ fn spawnDriver(driver: *Driver) void {
/// is the whole restart decision: a clean exit meant to stop; anything else
/// restarts with backoff until the crash-loop cap.
fn onDriverExit(driver: *Driver) void {
pruneChildrenOf(driver.process_id);
const reason = process.exitReason(driver.process_id) orelse .fault;
const dead = driver.process_id;
// One death, two notifications: the manager is this driver's supervisor (its
// spawn named this endpoint) *and*, since P4c put the watcher table in the
// harness, a subscriber to published exits. Both badges carry the same id, and
// the ring delivers them separately — so the id is retired here, before any
// decision is taken, and the second notification finds no driver to act on.
// Without this the backoff would count one death twice and the crash-loop cap
// would fire at half the deaths it names.
driver.process_id = 0;
pruneChildrenOf(dead);
const reason = process.exitReason(dead) orelse .fault;
if (reason == .exited) {
driver.state = .stopped;
std.log.info("{s} exited cleanly; not restarting", .{driver.name()});
@@ -395,59 +400,62 @@ fn initialise(endpoint: ipc.Handle) bool {
return true;
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
if (message.len < 1) return 0;
switch (message[0]) {
@intFromEnum(device_manager_protocol.Operation.child_added) => return onChildAdded(message, reply, sender),
@intFromEnum(device_manager_protocol.Operation.child_removed) => return onChildRemoved(message, reply, sender),
@intFromEnum(device_manager_protocol.Operation.enumerate) => return onEnumerate(reply),
@intFromEnum(device_manager_protocol.Operation.subscribe) => return onSubscribe(reply, capability),
@intFromEnum(device_manager_protocol.Operation.hello) => {},
else => return 0,
}
if (message.len < device_manager_protocol.hello_size) return 0;
const hello = std.mem.bytesToValue(device_manager_protocol.Hello, message[0..device_manager_protocol.hello_size]);
var status: i32 = 0;
if (hello.version != device_manager_protocol.version) {
status = -1;
std.log.info("refused hello (version {d}) from process {d}", .{ hello.version, sender });
} else if (driverByProcess(sender)) |driver| {
driver.state = .running;
std.log.info("hello from {s} (device {d})", .{ driver.name(), hello.device_id });
// Resilience drill (V6): once, kill the virtio-gpu driver a moment after it hellos, so
// the normal restart policy respawns it — the compositor must survive and re-attach.
if (test_scanout_restart_mode and !test_scanout_killed and std.mem.eql(u8, driver.name(), "/system/drivers/virtio-gpu")) {
test_scanout_killed = true;
test_kill_pid = sender;
test_kill_due_ns = time.clock() + 1_500_000_000;
_ = time.timerOnce(manager_endpoint, 1600);
}
} else {
status = -1;
std.log.info("hello from unknown process {d}", .{sender});
}
const hello_reply = device_manager_protocol.HelloReply{ .status = status };
@memcpy(reply[0..device_manager_protocol.reply_size], std.mem.asBytes(&hello_reply));
return device_manager_protocol.reply_size;
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
return Serve.dispatch({}, handlers, message, sender, arrived, reply);
}
/// A bus driver reported a discovered device: mirror it, and in
/// test-usb-restart mode kill the reporter once after its second child — the
/// `subscribe` and `unsubscribe` are absent on purpose: the harness answers both,
/// and its table is what `publish` fans out over.
const handlers = Serve.Handlers{
.hello = onHello,
.child_added = onChildAdded,
.child_removed = onChildRemoved,
.enumerate = onEnumerate,
};
/// The handshake. The device this driver was assigned is the packet's target.
fn onHello(_: void, invocation: Invocation(device_manager_protocol.Hello), _: Answer(void)) isize {
if (invocation.request.version != device_manager_protocol.version) {
std.log.info("refused hello (version {d}) from process {d}", .{ invocation.request.version, invocation.sender });
return -envelope.EPROTO;
}
const driver = driverByProcess(invocation.sender) orelse {
std.log.info("hello from unknown process {d}", .{invocation.sender});
return -envelope.EPERM;
};
driver.state = .running;
std.log.info("hello from {s} (device {d})", .{ driver.name(), invocation.target });
// Resilience drill (V6): once, kill the virtio-gpu driver a moment after it hellos, so
// the normal restart policy respawns it — the compositor must survive and re-attach.
if (test_scanout_restart_mode and !test_scanout_killed and std.mem.eql(u8, driver.name(), "/system/drivers/virtio-gpu")) {
test_scanout_killed = true;
test_kill_pid = invocation.sender;
test_kill_due_ns = time.clock() + 1_500_000_000;
_ = time.timerOnce(manager_endpoint, 1600);
}
return 0;
}
/// A bus driver reported a discovered device: mirror it, publish it, match a
/// driver for it — and in the restart drills kill the reporter once, the
/// deterministic trigger for prune -> backoff -> respawn -> re-report.
fn onChildAdded(message: []const u8, reply: []u8, sender: u32) usize {
if (message.len < device_manager_protocol.child_added_size) return 0;
const report = std.mem.bytesToValue(device_manager_protocol.ChildAdded, message[0..device_manager_protocol.child_added_size]);
var status: i32 = 0;
fn onChildAdded(_: void, invocation: Invocation(device_manager_protocol.ChildAdded), _: Answer(void)) isize {
const report = invocation.request;
const sender = invocation.sender;
// The registered kernel device id is the packet's target, not a field: what
// the manager hands a matched driver as its argv assignment.
const device_id = invocation.target;
var status: isize = 0;
if (driverByProcess(sender)) |driver| {
if (!addChild(report.parent, report.bus_address, report.identity, report.device_id, sender)) status = -1;
if (!addChild(report.parent, report.bus_address, report.identity, device_id, sender)) status = -envelope.ENOSPC;
std.log.info("child added (device {d} port {d}, identity {d}) by {s}", .{ report.parent, report.bus_address, report.identity, driver.name() });
if (status == 0) publishEvent(message[0..device_manager_protocol.child_added_size]);
if (status == 0) Serve.publish(.child_added, device_id, report);
// Matching from reports (M19.3), now data-driven via the /system/configuration/devices.csv
// registry: a registered child gets the most-specific driver its identity
// matches, once — re-reports after a bus restart dedupe on the registered
// id, exactly like the registrations do.
if (status == 0 and report.device_id != device_manager_protocol.no_device) {
if (status == 0 and device_id != device_manager_protocol.no_device) {
const id = identityFromReport(report);
if (registry.matchDriver(registry_rules[0..registry_count], id)) |match| {
if (match.ambiguous)
@@ -458,15 +466,13 @@ fn onChildAdded(message: []const u8, reply: []u8, sender: u32) usize {
if (!alreadySupervised(match.driver)) addDriver(match.driver, device_manager_protocol.no_device, false);
} else {
// A per-device driver: one instance, the registered id as argv[1].
if (!driverForDevice(report.device_id)) addDriver(match.driver, report.device_id, true);
if (!driverForDevice(device_id)) addDriver(match.driver, device_id, true);
}
}
}
} else {
status = -1;
status = -envelope.EPERM;
}
const report_reply = device_manager_protocol.ReportReply{ .status = status };
@memcpy(reply[0..@sizeOf(device_manager_protocol.ReportReply)], std.mem.asBytes(&report_reply));
if (test_pci_restart_mode and !test_usb_killed) {
if (driverByProcess(sender)) |driver| {
if (std.mem.eql(u8, driver.name(), "pci-bus") and childCountOf(sender) >= 3) {
@@ -494,64 +500,47 @@ fn onChildAdded(message: []const u8, reply: []u8, sender: u32) usize {
}
}
}
return @sizeOf(device_manager_protocol.ReportReply);
return status;
}
/// A bus driver reported a device gone (hot-unplug; no sender exists yet, but
/// the handler is protocol-complete — death-pruning covers removal until then).
fn onChildRemoved(message: []const u8, reply: []u8, sender: u32) usize {
if (message.len < device_manager_protocol.child_removed_size) return 0;
const report = std.mem.bytesToValue(device_manager_protocol.ChildRemoved, message[0..device_manager_protocol.child_removed_size]);
var status: i32 = -1;
/// A bus driver reported a device gone (hot-unplug). Addressed by the composite
/// (parent, bus address) the reporter knows, which is why that pair is the
/// packet's body rather than its target.
fn onChildRemoved(_: void, invocation: Invocation(device_manager_protocol.ChildRemoved), _: Answer(void)) isize {
const report = invocation.request;
var status: isize = -envelope.ENOENT;
for (&children) |*child| {
if (child.used and child.parent == report.parent and child.bus_address == report.bus_address and child.reporter == sender) {
if (child.used and child.parent == report.parent and child.bus_address == report.bus_address and child.reporter == invocation.sender) {
std.log.info("child removed (device {d} port {d})", .{ child.parent, child.bus_address });
child.used = false;
status = 0;
}
}
const report_reply = device_manager_protocol.ReportReply{ .status = status };
@memcpy(reply[0..@sizeOf(device_manager_protocol.ReportReply)], std.mem.asBytes(&report_reply));
return @sizeOf(device_manager_protocol.ReportReply);
return status;
}
/// An application asked for the tree: the mirror, as a header plus entries.
fn onEnumerate(reply: []u8) usize {
var count: u32 = 0;
var offset: usize = @sizeOf(device_manager_protocol.EnumerateReply);
/// The reserved `enumerate` verb: the mirror, one `ChildEntry` per known child,
/// packed into the reply's tail. How many arrived is the reply's own length —
/// `Status.len` — so no count header is spent saying it twice.
fn onEnumerate(_: void, _: Invocation(void), answer: Answer(void)) isize {
const entry_size = @sizeOf(device_manager_protocol.ChildEntry);
const tail = answer.tail();
var written: usize = 0;
for (&children) |*child| {
if (!child.used) continue;
if (offset + @sizeOf(device_manager_protocol.ChildEntry) > reply.len) break;
if (written + entry_size > tail.len) break;
const entry = device_manager_protocol.ChildEntry{ .parent = child.parent, .bus_address = child.bus_address, .identity = child.identity };
@memcpy(reply[offset..][0..@sizeOf(device_manager_protocol.ChildEntry)], std.mem.asBytes(&entry));
offset += @sizeOf(device_manager_protocol.ChildEntry);
count += 1;
@memcpy(tail[written..][0..entry_size], std.mem.asBytes(&entry));
written += entry_size;
}
const header = device_manager_protocol.EnumerateReply{ .status = 0, .count = count };
@memcpy(reply[0..@sizeOf(device_manager_protocol.EnumerateReply)], std.mem.asBytes(&header));
return offset;
}
/// An application subscribed: its endpoint arrived as the call's capability.
fn onSubscribe(reply: []u8, capability: ?ipc.Handle) usize {
var status: i32 = -1;
if (capability) |handle| {
for (&subscribers) |*slot| {
if (slot.* == null) {
slot.* = handle;
status = 0;
break;
}
}
}
const report_reply = device_manager_protocol.ReportReply{ .status = status };
@memcpy(reply[0..@sizeOf(device_manager_protocol.ReportReply)], std.mem.asBytes(&report_reply));
return @sizeOf(device_manager_protocol.ReportReply);
return @intCast(written);
}
fn onNotification(badge: u64) void {
if (badge & ipc.notify_exit_bit != 0) {
const dead: u32 = @intCast(badge & ~(ipc.notify_badge_bit | ipc.notify_exit_bit));
// The harness has already swept the watcher table for this death; what is
// left is the manager's own concern, its supervised drivers.
if (driverByProcess(dead)) |driver| onDriverExit(driver);
return;
}
@@ -566,9 +555,10 @@ pub fn main(init: process.Init) void {
test_scanout_restart_mode = std.mem.eql(u8, mode, "test-scanout-restart");
}
service.run(device_manager_protocol.message_maximum, .{
.service = .device_manager,
.service = "device-manager",
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,
.subscribers = Serve.hooks,
});
}
+33 -24
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) {
+3 -2
View File
@@ -10,8 +10,9 @@ pub fn build(b: *std.Build) void {
.name = "display",
.root_source_file = b.path("display.zig"),
.imports = &.{
"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", "process", "scanout-protocol",
"service", "thread", "time",
},
.threaded = true, // real atomics/TLS (docs/threading.md)
});
+201 -100
View File
@@ -16,8 +16,10 @@
//! (docs/display-v2.md).
const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc");
const input = @import("input-client");
const process = @import("process");
const Thread = @import("thread").Thread;
const service = @import("service");
const time = @import("time");
@@ -27,10 +29,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;
@@ -61,8 +75,22 @@ var background: u32 = 0;
/// small copies rather than one huge bounding box (see compositor.DamageList).
const maximum_layers = 16;
/// A layer belongs to the client that created it. `owner` is the kernel-stamped
/// badge of that task, and `service_owned` (0, an id no task wears) marks the
/// compositor's own layers — the cursor sprite and the self-check pair — which
/// this file creates by direct call rather than over the protocol.
///
/// Layer ids are slots in a sixteen-entry table: small, dense, and guessable, so
/// before this field any client could configure, draw into, or destroy any
/// other's layer — including the cursor. The check lives in the protocol handlers
/// (docs/os-development/protocol-namespace.md: handles validated against the
/// badge); the internal helpers stay unscoped precisely so the compositor can
/// still drive its own.
const service_owned: u32 = 0;
const Layer = struct {
used: bool = false,
owner: u32 = service_owned,
x: i32 = 0,
y: i32 = 0,
z: u32 = 0,
@@ -176,7 +204,8 @@ fn layerAt(id: u32) ?*Layer {
return &layers[id];
}
fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
/// Create a layer for `owner` — `service_owned` for the compositor's own.
fn createLayer(owner: u32, x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
if (w == 0 or h == 0) return null;
const slot = freeLayer() orelse return null;
const len = @as(usize, w) * h * 4;
@@ -184,6 +213,7 @@ fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32, visible: bool) ?u32 {
if (memory.mmapFailed(base)) return null;
layers[slot] = .{
.used = true,
.owner = owner,
.x = x,
.y = y,
.z = z,
@@ -307,11 +337,24 @@ fn verifyNativePresent() void {
/// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The
/// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
/// unblocks the driver and it starts serving `.scanout`.
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, capability: ?ipc.Handle, reply: []u8) usize {
const cap = capability 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 = ipc.lookup(.scanout) orelse return fail(reply);
/// The surface arrives as the call's capability, and the harness's ownership rule
/// applies: nothing here claims it, so the turn closes it on every path. Safe
/// because a **mapping holds its own kernel reference** (system/kernel/process.zig
/// `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 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.)
@@ -339,7 +382,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
@@ -394,8 +437,8 @@ fn selfCheck() void {
const format = backend.info().format;
const red = display_protocol.pack(format, 0xC0, 0x20, 0x20);
const green = display_protocol.pack(format, 0x20, 0xC0, 0x20);
const bottom = createLayer(100, 100, 80, 80, 0, true) orelse return fail_check("create");
const top = createLayer(140, 140, 80, 80, 1, true) orelse return fail_check("create");
const bottom = createLayer(service_owned, 100, 100, 80, 80, 0, true) orelse return fail_check("create");
const top = createLayer(service_owned, 140, 140, 80, 80, 1, true) orelse return fail_check("create");
_ = fillLayer(bottom, Rect.init(0, 0, 80, 80), red);
_ = fillLayer(top, Rect.init(0, 0, 80, 80), green);
present();
@@ -506,10 +549,13 @@ fn mouseListener(width: u32, height: u32) void {
return;
};
// Our own handle to the compositor's endpoint. IPC handles are per-thread, so we
// cannot reuse the main thread's service handle — we look the service up to install a
// handle in this thread's table. A poke posted here wakes the compositor loop parked
// in replyWait (docs/threading.md: handles do not cross threads).
cursor_channel.poke_endpoint = ipc.lookup(.display) orelse {
// cannot reuse the main thread's — this thread resolves and opens
// `/protocol/display` exactly like any other client would, once at startup, and
// gets its own handle. There is no special mechanism for reaching yourself: the
// registry does not know or care that the provider is this process. A poke posted
// here wakes the compositor loop parked in replyWait (docs/threading.md: handles
// do not cross threads).
cursor_channel.poke_endpoint = channel.openEndpoint("display") orelse {
_ = logging.write("display: mouse listener could not reach the compositor endpoint\n");
return;
};
@@ -557,7 +603,7 @@ fn startCursorTracking() void {
const mode = backend.info();
cursor_origin_x = @divTrunc(@as(i32, @intCast(mode.width)), 2);
cursor_origin_y = @divTrunc(@as(i32, @intCast(mode.height)), 2);
const id = createLayer(cursor_origin_x, cursor_origin_y, cursor_size, cursor_size, cursor_z, true) orelse {
const id = createLayer(service_owned, cursor_origin_x, cursor_origin_y, cursor_size, cursor_size, cursor_z, true) orelse {
_ = logging.write("display: could not create cursor layer\n");
return;
};
@@ -574,6 +620,9 @@ fn startCursorTracking() void {
fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint;
// Layers are per-client state, so the compositor needs deaths: a client that
// crashes leaves its surfaces on screen and its slots spent otherwise.
_ = process.subscribeExits(endpoint);
// Pick the scanout backend (GOP today). It logs the reason on failure.
backend = backend_mod.select() orelse return false;
@@ -599,104 +648,156 @@ 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.
/// The layer a packet addresses, **for the task that sent it**: null unless the
/// target names a used slot this sender created. A layer that is somebody else's
/// is refused exactly as one that never existed, so a client cannot use the
/// refusal to learn which ids are live (P3's refusal-equals-absence, applied to
/// ids rather than names).
fn targetLayer(target: u64, sender: u32) ?u32 {
if (target > std.math.maxInt(u32)) return null; // a layer id is a u32
const id: u32 = @intCast(target);
const layer = layerAt(id) orelse return null;
if (layer.owner != sender) return null;
return id;
}
fn ok(reply: []u8) usize {
return writeReply(reply, .{ .status = 0 });
}
fn fail(reply: []u8) usize {
return writeReply(reply, .{ .status = -1 });
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) 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, capability, 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),
/// Destroy every layer a dead client left behind — its surface is pages nobody
/// will ever draw into again, and its slot is one of sixteen. The published
/// exit events are the notice, the same sweep idiom the FAT server uses for open
/// files and the harness uses for subscribers.
fn releaseLayersOf(dead: u32) void {
var released: u32 = 0;
for (&layers, 0..) |*layer, id| {
if (layer.used and layer.owner == dead) {
_ = destroyLayer(@intCast(id));
released += 1;
}
}
if (released != 0) {
std.log.info("released {d} layer(s) for dead client {d}", .{ released, dead });
schedulePresent(); // the screen still shows what they painted
}
}
/// Two notification sources reach the compositor, and one coalesced badge can carry
/// both, so each bit is handled independently. A **message-notification** is a poke from
/// the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): fold the
/// newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the frame
/// clock — or the deferred first native present after `attach_scanout` — either way,
/// present the accumulated damage.
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 onCreateLayer(_: void, invocation: Invocation(display_protocol.CreateLayer), answer: Answer(display_protocol.Created)) isize {
const request = invocation.request;
const slot = createLayer(invocation.sender, 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, invocation.sender) 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, invocation.sender) 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, invocation.sender) 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, invocation.sender) 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, invocation.sender) 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 {
// 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);
}
/// Three notification sources reach the compositor, and one coalesced badge can carry
/// more than one, so each bit is handled independently. A **message-notification** is a
/// poke from the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`):
/// fold the newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the
/// frame clock — or the deferred first native present after `attach_scanout` — either way,
/// present the accumulated damage. A **published exit** (`notify_exit_bit`) is a client
/// gone: release the layers it left.
fn onNotification(badge: u64) void {
if (badge & ipc.notify_exit_bit != 0) {
releaseLayersOf(@intCast(badge & ~(ipc.notify_badge_bit | ipc.notify_exit_bit)));
return;
}
if (badge & ipc.notify_message_bit != 0) renderCursor();
if (badge & ipc.notify_timer_bit != 0) frameTick();
}
pub fn main() void {
service.run(display_protocol.message_maximum, .{
.service = .display,
.service = "display",
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,
+2 -2
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);
+164 -102
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
@@ -57,8 +66,9 @@ var ipc_block: IpcBlock = undefined;
var device_dirty: bool = false;
var filesystem: engine.FileSystem = undefined;
// Open handles the VFS holds against this backend: each maps a node id to a
// resolved engine node.
// Open handles clients hold against this backend: each maps a node id to a
// resolved engine node, and to the client that opened it. `owner` is the
// kernel-stamped badge of the opening task — the only source identity there is.
const OpenNode = struct { used: bool = false, node: engine.Node = undefined, owner: u32 = 0 };
var open_nodes = [_]OpenNode{.{}} ** 32;
@@ -69,22 +79,32 @@ fn allocOpen() ?usize {
return null;
}
fn openAt(id: u64) ?*OpenNode {
/// The open node `id` names **for `owner`** — null unless the id is in range, in
/// use, and this client's own. Node ids are small integers drawn from a table of
/// thirty-two, so they are trivially guessable; before this check every client
/// honoured every other client's ids, which is the hole
/// docs/os-development/protocol-namespace.md names ("handles must be scoped per
/// client — validated against the badge"). Nothing else about them changed: they
/// are still per-session, still swept when their owner dies.
///
/// The owner is a *task*, not a process, because the badge is: a threaded client
/// reads and writes a node from the thread that opened it, exactly as the exit
/// sweep already released a worker thread's handles when that thread died.
fn openFor(id: u64, owner: u32) ?*OpenNode {
if (id >= open_nodes.len) return null;
const o = &open_nodes[@intCast(id)];
return if (o.used) o else null;
if (!o.used or o.owner != owner) return null;
return o;
}
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 node that is someone else's, 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 — and because
/// *someone else's* must be indistinguishable from *nobody's*, or the refusal
/// would itself tell a prober which ids are live (the same discipline the
/// protocol namespace's refused open follows).
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,114 +224,156 @@ 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 onMessage(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle) usize {
_ = capability;
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..];
fn onRead(_: void, invocation: Invocation(vfs_protocol.Read), answer: Answer(void)) isize {
const o = openFor(invocation.target, invocation.sender) 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 = openFor(invocation.target, invocation.sender) 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 = openFor(invocation.target, invocation.sender) 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 = openFor(invocation.target, invocation.sender) 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);
}
/// Closing is an operation on a node like any other, so it is scoped like any
/// other: a client may release its own handles and nobody else's. An id that is
/// not the caller's — free, out of range, or another client's — is refused
/// identically, so a close cannot be used to ask which ids are live either.
fn onClose(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const o = openFor(invocation.target, invocation.sender) orelse return refused;
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
/// callback used to discard a `?ipc.Handle` and every request carrying one — a
/// legal thing for any client to do — spent a slot of the VFS server's
/// thirty-two until it could accept no capability at all.
fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = arrived;
// 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.
.mount, .unmount => return fail(out),
}
return Serve.dispatch({}, handlers, message, sender, null, out);
}
pub fn main() void {
service.run(vfs_protocol.message_maximum, .{
.service = .fat,
.service = "vfs",
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,
+2 -2
View File
@@ -11,8 +11,8 @@ pub fn build(b: *std.Build) void {
.name = "init",
.root_source_file = b.path("init.zig"),
.imports = &.{
"csv", "file-system", "ipc", "logging", "memory", "power-protocol",
"process", "time",
"csv", "envelope", "file-system", "ipc", "logging", "memory", "power-protocol",
"process", "time", "vfs-protocol",
},
});
// init reads the same `serial` flag the kernel does: its liveness heartbeat
+798 -58
View File
@@ -16,6 +16,34 @@
//! power-button event runs the stop sequence over its children in reverse order
//! before asking the power service to enter S5 — lifecycle (M17) and events (M21)
//! composing into a clean poweroff.
//!
//! P2: init is also the **registrar** — it serves `/protocol`, the namespace where
//! a program finds everything it talks to
//! (docs/os-development/protocol-namespace.md). It is the natural home: it already
//! spawns the services and already holds the supervision link to each, so it is the
//! process that *knows* which binary is which. Registry traffic rides the same
//! endpoint as supervision, because one thread can only wait in one place — the
//! loop below answers vfs `open`/`readdir`/`bind` alongside signals, timers, power
//! events, and children's deaths.
//!
//! Which sets the security posture of this file. `fs_resolve` installs a mounted
//! backend's endpoint capability in *any* caller's handle table, so sharing the
//! mailbox means **every ring-3 process can send into PID 1**. Two rules follow,
//! and both are structural here rather than remembered per branch:
//!
//! - **Privileged action requires an attested sender.** What arrives is a
//! stranger's bytes; the only identity on it is the task id the kernel stamps.
//! Content never authorizes (`onPowerEvent`), and neither does a name — the
//! registrar attests a caller's supervision by task id (`supervisorSatisfies`).
//! - **Absence is the enforcement.** P3: `open` consults the manifest with the
//! same attested identity a `bind` does, and a caller with no grant is told
//! exactly what a caller asking for a name nobody bound is told — `-ENOENT`,
//! and no capability (`onOpen`). Restriction stage one of
//! docs/os-development/protocol-namespace.md: what a process cannot open does
//! not exist for it, so there is no "permission denied" to distinguish.
//! - **A capability that arrives is closed unless it is claimed** (`Arrival`),
//! because PID 1's thirty-two handle slots are a resource an unauthenticated
//! caller would otherwise be able to spend.
const std = @import("std");
const ipc = @import("ipc");
@@ -24,6 +52,8 @@ const time = @import("time");
const memory = @import("memory");
const logging = @import("logging");
const power_protocol = @import("power-protocol");
const vfs_protocol = @import("vfs-protocol");
const envelope = @import("envelope");
const build_options = @import("build_options");
const fs = @import("file-system");
const csv = @import("csv");
@@ -73,17 +103,10 @@ var supervision_endpoint: ipc.Handle = 0;
/// uses for /system/configuration/devices.csv. A missing file means no services (the no-ramdisk
/// isolation test): loud, but not fatal.
fn loadServices() void {
var file = fs.open("/system/configuration/init.csv", .{}) orelse {
const used = readConfiguration("/system/configuration/init.csv", &init_csv) orelse {
_ = logging.write("/system/services/init: /system/configuration/init.csv missing — no services started\n");
return;
};
defer file.close();
var used: usize = 0;
while (used < init_csv.len) {
const n = file.read(init_csv[used..]) orelse break;
if (n == 0) break;
used += n;
}
var lines = std.mem.splitScalar(u8, init_csv[0..used], '\n');
while (lines.next()) |line| {
const body = csv.stripComment(line);
@@ -107,11 +130,607 @@ fn loadServices() void {
}
}
/// Read a whole configuration file into `into`, returning the byte count. Both of
/// init's manifests live in the initial ramdisk the kernel serves directly, so this
/// works before any filesystem service exists. A file that fills the buffer exactly
/// is reported: a manifest silently losing its last rows is a policy change nobody
/// asked for, and the symptom (one service refused a name) points nowhere near it.
fn readConfiguration(path: []const u8, into: []u8) ?usize {
var file = fs.open(path, .{}) orelse return null;
defer file.close();
var used: usize = 0;
while (used < into.len) {
const n = file.read(into[used..]) orelse break;
if (n == 0) break;
used += n;
}
if (used == into.len) std.log.info("{s} filled the read buffer — rows past {d} bytes are lost", .{ path, used });
return used;
}
/// Give up restarting a service after this many crashes — a crash-loop cap, so a service
/// that faults immediately on every spawn doesn't respawn forever.
const maximum_restarts = 3;
pub fn main() void {
// --- the registry: /protocol ------------------------------------------------
/// Longest contract name the namespace admits (`display`, `test/shared-memory`)
/// and the most that may be bound at once. Both static, like everything else
/// init holds.
const maximum_name = 64;
const maximum_bindings = 16;
const maximum_grants = 64;
/// One bound contract: the name, the provider's endpoint (a capability init
/// holds and hands to whoever opens the name), and the provenance a diagnostic
/// listing answers "who serves this?" with.
const Binding = struct {
used: bool = false,
name: [maximum_name]u8 = undefined,
name_len: usize = 0,
endpoint: ipc.Handle = 0,
task: u32 = 0,
binary: [64]u8 = undefined,
binary_len: usize = 0,
fn nameSlice(self: *const Binding) []const u8 {
return self.name[0..self.name_len];
}
fn binarySlice(self: *const Binding) []const u8 {
return self.binary[0..self.binary_len];
}
};
var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings;
/// What a grant row permits.
///
/// - `bind` — claim the name, i.e. provide the contract.
/// - `open` — reach the name, i.e. speak the contract to whoever provides it.
/// - `supervise` — stand in a third task's supervision chain: a task running this
/// binary, under this supervisor, may be the supervising task named by an
/// `open` row for this contract. It grants the *delegate* nothing itself.
///
/// `supervise` exists because attestation is deliberately one hop deep
/// (`supervisorSatisfies`): init vouches only for tasks it or the kernel started.
/// The driver tree is deeper than that — the device manager starts the PS/2 bus,
/// and the bus starts the keyboard and mouse drivers — so without a way to say
/// "this task is an authorized supervisor", a legitimate grandchild would be
/// indistinguishable from a laundering deputy. Naming the delegate in the
/// manifest is what tells them apart, and it is the same shape as every other
/// row: a binary, the supervisor it must have, and the contract it concerns.
/// Deliberately `open`-only — a delegate may vouch for what its children may
/// *reach*, never for what they may *claim* — so the bind path's attestation is
/// exactly what P2 shipped and every refusal it makes still holds.
const Permission = enum { bind, open, supervise };
/// One row of `/system/configuration/protocol.csv`. Every field may end in `*`,
/// which matches any tail — the subtree scoping the design doc describes, and
/// what lets one row grant the whole `/test/` family its `test/...` names.
const Grant = struct {
binary: []const u8 = "",
supervisor: []const u8 = "",
permission: Permission = .bind,
name: []const u8 = "",
};
/// Roomier than init.csv's: this manifest carries a row per provider per spawn
/// path, a row per client per contract it reaches, and its own format
/// documentation — which is most of the bytes, and is the point of the file.
var protocol_csv: [16384]u8 = undefined;
var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants;
var grant_count: usize = 0;
/// Parse `/system/configuration/protocol.csv` — the grant manifest. Separate from
/// init.csv because every field there after the path is argv, and overloading that
/// would be ambiguous; separate *files* also means a grant exists for binaries init
/// never spawns (the drivers, which the device manager owns).
fn loadGrants() void {
const used = readConfiguration("/system/configuration/protocol.csv", &protocol_csv) orelse {
_ = logging.write("/system/services/init: /system/configuration/protocol.csv missing — no protocol may be bound\n");
return;
};
var lines = std.mem.splitScalar(u8, protocol_csv[0..used], '\n');
while (lines.next()) |line| {
const body = csv.stripComment(line);
if (body.len == 0) continue;
if (grant_count >= grants.len) {
_ = logging.write("/system/services/init: /system/configuration/protocol.csv has more rows than the table holds\n");
break;
}
var it = csv.fields(body);
const binary = it.next() orelse continue;
const supervisor = it.next() orelse continue;
const permission = it.next() orelse continue;
const name = it.next() orelse continue;
if (binary.len == 0 or supervisor.len == 0 or name.len == 0) continue;
const kind: Permission = if (std.mem.eql(u8, permission, "bind"))
.bind
else if (std.mem.eql(u8, permission, "open"))
.open
else if (std.mem.eql(u8, permission, "supervise"))
.supervise
else
continue; // an unreadable row grants nothing rather than something wrong
grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name };
grant_count += 1;
}
}
/// Match a manifest field against a value: exact, or a trailing `*` matching any
/// tail. The wildcard is how a subtree is granted whole (`/test/*` for every test
/// fixture, `test/*` for every name they may claim).
fn matches(pattern: []const u8, value: []const u8) bool {
if (pattern.len != 0 and pattern[pattern.len - 1] == '*') {
const prefix = pattern[0 .. pattern.len - 1];
return value.len >= prefix.len and std.mem.eql(u8, value[0..prefix.len], prefix);
}
return std.mem.eql(u8, pattern, value);
}
/// A snapshot of the kernel's process records — the only identity in the system
/// that cannot be forged, because the kernel stamps it at spawn. Refreshed per
/// authorization; binds are rare, so the copy costs nothing that matters.
var process_table: [64]process.ProcessDescriptor = undefined;
var process_count: usize = 0;
var process_truncated = false;
fn refreshProcessTable() void {
const total = process.processes(&process_table);
process_count = @min(total, process_table.len);
process_truncated = total > process_table.len;
}
/// Whether task `id` is still alive, as the last snapshot saw it. A snapshot that
/// did not fit answers "alive" for anything it did not list: refusing a bind is
/// recoverable, stealing a live provider's name is not.
fn taskAlive(id: u32) bool {
return descriptorOf(id) != null or process_truncated;
}
fn descriptorOf(id: u32) ?*const process.ProcessDescriptor {
for (process_table[0..process_count]) |*descriptor| {
if (descriptor.id == id) return descriptor;
}
return null;
}
fn nameOf(descriptor: *const process.ProcessDescriptor) []const u8 {
const length = @min(@as(usize, descriptor.name_length), descriptor.name.len);
return descriptor.name[0..length];
}
/// The name a kernel task answers to in a grant row. Kernel tasks carry no
/// binary, so the manifest spells the harness's parentage `kernel`.
const kernel_supervisor = "kernel";
/// init's own task id, read once at startup. Ids are monotonic and never reused
/// (system/kernel/process.zig), so an id comparison is an *identity* test where a
/// name comparison is only a resemblance test — the whole basis of the
/// attestation below.
var own_task: u32 = 0;
/// Whether `id` is a process THIS init spawned: a lookup in its own child table,
/// which is the one record of "I started that one" nobody else can write.
fn spawnedByUs(id: u32) bool {
if (id == 0) return false;
for (child_ids[0..service_count]) |child| {
if (child == id) return true;
}
return false;
}
/// Who is asking, attested by the kernel: the caller's binary, and the **task**
/// that spawned it — an id, not a name.
///
/// A name alone is not identity: `spawn` is ungated, so a hostile process can
/// start a granted binary itself and would inherit its grants. Neither is the
/// supervisor's *name* enough, and this is the trap the first cut fell into —
/// init and the device manager are ordinary bundled binaries, so an attacker
/// spawns its own `/system/services/init` and lets that instance spawn
/// `/system/services/input`. Both kernel-stamped names then match the grant row
/// exactly, and walking to the root of the chain does not help either: the
/// laundered chain still roots at the real PID 1. What refuses it is asking
/// *which task* the supervisor is, and only accepting one init can vouch for.
const Identity = struct {
/// The caller's binary path, exactly as the kernel stamped it at spawn.
binary: []const u8,
/// The supervising task's id. 0 means the kernel spawned the caller, which
/// no ring-3 process can arrange: every `system_spawn` stamps the caller as
/// the child's supervisor (system/kernel/process.zig `systemSpawn`).
supervisor_task: u32,
/// The supervising task's kernel-stamped binary — the grant row's supervisor
/// column is matched against this, and the refusal log prints it. `kernel`
/// when there is no supervising task.
supervisor_binary: []const u8,
/// Whether init can vouch for how the supervising task came to exist: it is
/// this init, a process this init spawned, or a process the KERNEL spawned.
/// A supervisor init cannot vouch for satisfies no row, however well its
/// name reads — that is the laundering deputy's refusal.
supervisor_vouched: bool,
};
/// The process a task belongs to. A thread resolves to its leader: threads share
/// a binary (a thread's own record is named `thread`), and the supervision link
/// that matters is the process's.
fn leaderOf(descriptor: *const process.ProcessDescriptor) *const process.ProcessDescriptor {
if (descriptor.leader == descriptor.id) return descriptor;
return descriptorOf(descriptor.leader) orelse descriptor;
}
/// Resolve the badge on a request into an identity, one hop up the supervision
/// chain in the kernel's records — one hop is enough because the hop is attested
/// by id (see `supervisorSatisfies`), and every id in the chain init accepts is
/// one init or the kernel created.
fn identify(task: u32) ?Identity {
const caller = descriptorOf(task) orelse return null;
const leader = leaderOf(caller);
if (leader.supervisor == 0) return .{
.binary = nameOf(leader),
.supervisor_task = 0,
.supervisor_binary = kernel_supervisor,
.supervisor_vouched = true, // the kernel is the root of trust, not a claimant
};
// The supervising *task* may be a worker thread of the supervising process;
// its process is what the manifest names and what init recorded at spawn.
const supervisor = leaderOf(descriptorOf(leader.supervisor) orelse return null); // unattestable: refuse
return .{
.binary = nameOf(leader),
.supervisor_task = supervisor.id,
.supervisor_binary = nameOf(supervisor),
.supervisor_vouched = supervisor.id == own_task or
spawnedByUs(supervisor.id) or
supervisor.supervisor == 0,
};
}
/// Whether the caller's supervising task satisfies a grant row's supervisor
/// column. The column names *the authorized supervising task*, matched by
/// identity — the binary it must be, plus proof that this instance of that
/// binary is the authorized one:
///
/// - `kernel` is satisfied only by a genuinely kernel-spawned caller
/// (supervisor id 0). A ring-3 process cannot manufacture that: user
/// `system_spawn` always stamps the caller (system/kernel/process.zig).
/// - init's own binary is satisfied only when the supervising task IS this
/// init (`own_task`).
/// - any other binary — the device manager, a test fixture spawning another —
/// is satisfied only when the supervising task is one init spawned itself
/// (its own child table) or one the kernel spawned. Everything init and the
/// kernel start is therefore reachable; a chain that passes through a
/// process *neither* of them started is not.
fn supervisorSatisfies(column: []const u8, identity: Identity) bool {
if (std.mem.eql(u8, column, kernel_supervisor)) return identity.supervisor_task == 0;
if (identity.supervisor_task == 0) return false; // a kernel task answers to no binary column
if (!matches(column, identity.supervisor_binary)) return false;
return identity.supervisor_vouched;
}
/// Whether `identity` is granted `permission` on `name`.
fn granted(identity: Identity, permission: Permission, name: []const u8) bool {
for (grants[0..grant_count]) |grant| {
if (grant.permission != permission) continue;
if (!matches(grant.binary, identity.binary)) continue;
if (!supervisorSatisfies(grant.supervisor, identity)) continue;
if (!matches(grant.name, name)) continue;
return true;
}
return false;
}
/// Whether `identity` may reach `name` — `granted(.open, …)`, plus the one hop
/// `open` takes that `bind` does not (`Permission.supervise`).
///
/// The hop is needed because the driver tree is three deep and attestation is
/// one: the PS/2 keyboard driver's supervising task is the PS/2 bus driver,
/// which the device manager started, which init started. Init cannot vouch for
/// the bus by acquaintance — it never met it — so the manifest says so instead,
/// and says it per contract: `ps2-bus` may be the supervisor named in an `open`
/// grant for `ps2-bus` and for `input`, and for nothing else.
fn mayOpen(identity: Identity, name: []const u8) bool {
if (granted(identity, .open, name)) return true;
return delegatedOpen(identity, name);
}
/// The delegated `open`: the row's supervisor column names the caller's actual
/// supervising task by binary, that task is one init cannot vouch for directly,
/// and a `supervise` row authorizes it for exactly this contract.
///
/// The delegate itself is attested the ordinary way (`granted` → strict
/// `supervisorSatisfies`), so the chain is still anchored one hop above it in
/// init or the kernel and the recursion stops there. Two hops of manifest, never
/// an unbounded walk — a laundering deputy is refused at the first hop nobody
/// wrote a row for.
fn delegatedOpen(identity: Identity, name: []const u8) bool {
if (identity.supervisor_task == 0) return false; // a kernel-spawned caller needs no delegate
if (identity.supervisor_vouched) return false; // already answered by `granted` above
const delegate = identify(identity.supervisor_task) orelse return false;
if (!granted(delegate, .supervise, name)) return false;
for (grants[0..grant_count]) |grant| {
if (grant.permission != .open) continue;
if (!matches(grant.binary, identity.binary)) continue;
if (!matches(grant.supervisor, identity.supervisor_binary)) continue;
if (!matches(grant.name, name)) continue;
return true;
}
return false;
}
fn findBinding(name: []const u8) ?*Binding {
for (&bindings) |*binding| {
if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding;
}
return null;
}
/// Release a binding: the provider's endpoint capability goes back to the handle
/// table, and the name is free for the next claimant. init's own cached power
/// channel goes with it — a closed handle number is reused by the next capability
/// that arrives, and a stale copy would quietly aim the shutdown call at a
/// stranger. So does the authorized power *task*: nothing may speak for a
/// contract nobody holds.
fn releaseBinding(binding: *Binding) void {
if (std.mem.eql(u8, binding.nameSlice(), power_contract)) {
power_endpoint = null;
power_task = null;
power_pending = false;
}
_ = ipc.close(binding.endpoint);
binding.* = .{};
}
/// Drop every name a dead process held. Called when a supervised child dies (so
/// the restarted instance can bind again) and whenever a bind finds the current
/// owner gone — providers init does not supervise need the second path.
fn unbindTask(task: u32) void {
for (&bindings) |*binding| {
if (binding.used and binding.task == task) {
std.log.info("/protocol/{s} released ({s} is gone)", .{ binding.nameSlice(), binding.binarySlice() });
releaseBinding(binding);
}
}
}
/// A contract name as the namespace spells it: the mount-relative path a resolve
/// hands us ("/display") and the name a bind sends ("display") are the same thing
/// with and without a leading slash, so one normaliser serves both. Empty or
/// longer than the namespace admits is not a name.
fn contractName(raw: []const u8) ?[]const u8 {
const name = if (raw.len != 0 and raw[0] == '/') raw[1..] else raw;
if (name.len == 0 or name.len > maximum_name) return null;
return name;
}
/// The provider's endpoint that will ride the *next* reply, when the request was
/// an `open` that found its contract.
var pending_capability: ?ipc.Handle = null;
/// The ownership rule for a capability that arrives with a turn of the loop —
/// **the turn owns it until a handler takes it, and closes whatever is left** —
/// lives in `ipc.Arrival`, next to `replyWait`, because it is not PID 1's rule:
/// the service harness every other service runs (library/kernel/service.zig) had
/// the identical hole and now states the identical contract.
const Arrival = ipc.Arrival;
/// The one contract init is itself a client of. It never resolves the name — it
/// *is* the registry, so it reads its own table; the binding is what hands it the
/// channel.
const power_contract = "power";
/// Set when `power` is bound: init subscribes to it on the next turn of the loop,
/// never inside the bind — the provider is blocked on our reply until then, so
/// calling it here would deadlock the pair.
var power_pending = false;
var power_endpoint: ?ipc.Handle = null;
/// The one task authorized to deliver power events: whoever holds the `power`
/// binding. Recorded at the bind and cleared with the binding, so a provider that
/// dies and rebinds re-derives it with no further ceremony.
///
/// This is the *authentication* for the shutdown path. init's registry endpoint
/// is its supervision endpoint, and `fs_resolve("/protocol")` installs a sendable
/// handle to it in any caller's table — so after P2 every ring-3 process can post
/// into PID 1's mailbox. A power event may therefore never be believed on the
/// strength of its payload; it is believed because the kernel stamped the
/// sender's task id on it and that id is the provider's.
var power_task: ?u32 = null;
/// Whether the heartbeat's re-arming timer is running. A timer landing carries no
/// identity, so the loop cannot tell one timer from another — which means exactly
/// one may ever be in flight, or every landing re-arms and the beat doubles. (It
/// did: two beats a second is enough extra chatter to cut a driver's echoed line
/// in half on the shared serial stream.) So the deferred power subscribe borrows
/// the heartbeat's tick when there is one, and arms its own only when there is not.
var heartbeat_running = false;
/// Answer one registry request. Writes a vfs-protocol reply into `reply` and
/// returns its length; a capability the reply must carry lands in
/// `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 < 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 body = request_bytes[envelope.prefix_size..];
if (operation == @intFromEnum(vfs_protocol.Operation.bind))
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)) {
// `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);
}
/// 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
/// point of the call, so a bind without one is malformed. Every refusal below
/// simply returns: the endpoint stays the turn's, and the turn closes it — which
/// is why there is not one `ipc.close` on the way out of any of the six of them.
/// The success path is the only one that says anything about ownership, because
/// it is the only one that keeps the capability.
fn onBind(sender: u32, raw_name: []const u8, arrived: *Arrival) i32 {
if (arrived.peek() == null) return -envelope.EPROTO;
const name = contractName(raw_name) orelse return -envelope.ENOENT;
refreshProcessTable();
const identity = identify(sender) orelse return -envelope.EPERM;
if (!granted(identity, .bind, name)) {
std.log.info("refused bind of /protocol/{s} by {s} (pid {d}, supervisor {s} pid {d})", .{
name,
identity.binary,
sender,
identity.supervisor_binary,
identity.supervisor_task,
});
return -envelope.EPERM;
}
if (findBinding(name)) |existing| {
// Collision is an error — never last-writer-wins — unless the incumbent
// is dead, which is how a restarted provider retakes its own name.
if (taskAlive(existing.task)) {
std.log.info("refused bind of /protocol/{s}: held by {s} (pid {d})", .{ name, existing.binarySlice(), existing.task });
return -envelope.EBUSY;
}
releaseBinding(existing);
}
const slot = for (&bindings) |*binding| {
if (!binding.used) break binding;
} else return -envelope.ENOSPC;
// Claimed: the binding owns the endpoint from here, and `releaseBinding` is
// what closes it.
const endpoint = arrived.take().?;
slot.* = .{ .used = true, .endpoint = endpoint, .task = sender };
@memcpy(slot.name[0..name.len], name);
slot.name_len = name.len;
const binary_len = @min(identity.binary.len, slot.binary.len);
@memcpy(slot.binary[0..binary_len], identity.binary[0..binary_len]);
slot.binary_len = binary_len;
// Provenance, at the moment it becomes true: name -> pid -> binary path.
std.log.info("/protocol/{s} -> pid {d} {s}", .{ name, sender, slot.binarySlice() });
if (std.mem.eql(u8, name, power_contract)) {
power_endpoint = endpoint;
// The bind is also the authentication: whoever holds `power` is the one
// task whose power events init will act on (see `onPowerEvent`).
power_task = sender;
power_pending = true;
// Wake ourselves once the reply has gone out; the subscribe call cannot
// happen while the power service is still blocked on it. The heartbeat's
// tick is that wake when it is running — see `heartbeat_running`.
if (!heartbeat_running) _ = time.timerOnce(supervision_endpoint, 1);
}
return 0;
}
/// `open(name)` -> the provider's endpoint, delivered as the reply's capability.
///
/// **A refusal and an absence are the same answer, and that is the whole point.**
/// The namespace is the restriction (docs/os-development/protocol-namespace.md):
/// what a process may open is what exists for it, so "you may not have this" and
/// "there is no such thing" collapse into one reply — `-ENOENT`, no payload, no
/// capability. A caller therefore has no oracle: it cannot use `open` to learn
/// that a contract it lacks is bound, and — the reason this matters beyond
/// tidiness — stage two's supervisor can refuse, stall for a human, or substitute
/// a fake without the child being able to tell which happened.
///
/// Indistinguishable is a claim about *work done*, not only about the bytes, so
/// both questions are asked on every open whatever the first one answers: the
/// process table is refreshed, the caller identified, the grants scanned and the
/// bindings scanned, and only then is the single verdict formed. Nothing here
/// logs, either — `klog_read` is ungated (system/kernel/process.zig), so a line
/// written on one branch is a line the refused caller can read, and a serial line
/// costs milliseconds it could time. The operator's diagnosis is the pair the
/// namespace already publishes on purpose: `readdir` over `/protocol` says what is
/// bound, `/system/configuration/protocol.csv` says who may reach it, and the
/// client's own retry loop says which one it wanted.
///
/// (Not constant-time in the cryptographic sense, and not claimed to be: the two
/// scans stop at the row they match, and the optimiser is free to sink a pure
/// table walk past a branch that discards it. What is removed is the difference a
/// caller could actually measure or read — a syscall on one branch and not the
/// 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);
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);
const found = binding orelse return answer(reply, -envelope.ENOENT, 0);
pending_capability = found.endpoint;
// 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
/// protocol lists any directory: kind `protocol`, the contract's name, and the
/// provider's task id in `size`, so a plain listing answers "who serves this?".
fn onReaddir(reply: []u8, cursor: u64) usize {
var index: u64 = 0;
for (&bindings) |*binding| {
if (!binding.used) continue;
if (index != cursor) {
index += 1;
continue;
}
const name = binding.nameSlice();
return writeEntry(reply, .{
.kind = @intFromEnum(vfs_protocol.NodeKind.protocol),
.name_len = @intCast(name.len),
.size = binding.task,
}, name);
}
// 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 {
// `registry` is the scenario mode: serve /protocol and nothing else. The
// kernel test harness spawns its own providers directly, so it wants the
// naming layer up without init's whole service list underneath it
// (docs/security-track-plan.md, decision 9).
const registry_only = if (startup.arguments.get(1)) |role| std.mem.eql(u8, role, "registry") else false;
// Prove the heap end to end: allocate through the runtime allocator (which
// mmaps pages from the kernel and carves them with the free list), write into
// that heap buffer (exercising the widened debug_write bounds check), and
@@ -128,65 +747,178 @@ pub fn main() void {
// One endpoint carries everything init waits on: children's exit
// notifications (they are spawned supervised against it), init's own
// signals, and power events it subscribes to. All arrive in the loop below.
// signals, power events it subscribes to, and — since PID 1 is the registrar
// — every /protocol request. One thread can wait in one place, so they share
// a mailbox and the loop below tells them apart.
supervision_endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("/system/services/init: no endpoint\n");
return;
};
_ = process.bindSignals(supervision_endpoint);
// Our own id, before anything can ask us a question. It is half of the
// registrar's authority: a grant row naming init as the supervisor is
// satisfied by *this* task and no other instance of this binary
// (`supervisorSatisfies`).
own_task = process.taskId();
// The namespace goes up BEFORE anything is spawned, so a service's first
// bind lands rather than retrying. The kernel reserves the prefix: this
// mount is the only one it will ever hold.
loadGrants();
if (!fs.mount("/protocol", supervision_endpoint)) {
_ = logging.write("/system/services/init: /protocol already mounted — not the registrar\n");
}
// Load the service list, then bring each up supervised so init can stop them
// cleanly. Best-effort and silent: each service announces its own readiness,
// and with no /system/configuration/init.csv (an isolation test) the loop starts nothing.
loadServices();
for (services[0..service_count], 0..) |*service, i| {
if (process.spawnSupervised(service.path, service.arguments(), supervision_endpoint)) |id| child_ids[i] = id;
if (!registry_only) {
loadServices();
for (services[0..service_count], 0..) |*service, i| {
if (process.spawnSupervised(service.path, service.arguments(), supervision_endpoint)) |id| child_ids[i] = id;
}
}
// Subscribe to power events (retry: the power service registers well after
// init starts). Best-effort — without it, a `terminate` signal still
// triggers the same shutdown path.
subscribePower();
// A re-arming timer drives the liveness heartbeat — proof PID 1 is alive (the
// init test's marker) and a -Dserial diagnostic. It is a serial/test-build-only
// concern: a flashable (serial-off) image runs a purely event-driven PID 1 that
// wakes only for real work (signals, power events, children's exits), never for a
// periodic beat. `build_options.serial` is comptime, so the heartbeat — its timer
// and the handler below — folds away entirely when serial is off.
if (build_options.serial) _ = time.timerOnce(supervision_endpoint, 1000);
heartbeat_running = build_options.serial and !registry_only;
if (heartbeat_running) _ = time.timerOnce(supervision_endpoint, 1000);
var receive: [power_protocol.message_maximum]u8 = undefined;
var receive: [vfs_protocol.message_maximum]u8 = undefined;
var reply_buffer: [vfs_protocol.message_maximum]u8 = undefined;
var reply_len: usize = 0;
var reply_capability: ?ipc.Handle = null;
while (true) {
const got = ipc.replyWait(supervision_endpoint, &.{}, &receive, null);
if (process.signalsFrom(got.badge)) |signals| {
if (signals.has(.terminate)) shutDown();
continue;
const got = ipc.replyWait(supervision_endpoint, reply_buffer[0..reply_len], &receive, reply_capability);
reply_len = 0; // nothing owed until this turn's request says otherwise
reply_capability = null;
// Whatever capability came with this turn is the turn's, and the turn
// closes it unless a handler claims it. Structural on purpose — see
// `Arrival`; it is what keeps a zero-length call from spending a handle
// slot of PID 1's per call.
var arrived: Arrival = .{ .handle = got.cap };
defer arrived.release();
if (got.isNotification()) {
// Every badge on this branch is stamped by the KERNEL, and a stranger
// cannot stamp one: `ipc_send` — the only way a ring-3 process puts
// something in this mailbox with no reply owed — sets exactly
// `notify_badge_bit | notify_message_bit` and fills the low bits with
// the sender's own task id (system/kernel/ipc-synchronous.zig,
// `sendLocked`).
//
// That is a statement about `ipc_send`, and on its own it proved far
// too little: an attacker does not use `ipc_send` to forge a signal,
// it asks the kernel to deliver a real one *here*. `fs_resolve`
// hands any process a sendable handle to this endpoint, and
// `signal_bind`/`timer_bind`/`process_subscribe`/spawn's exit
// endpoint all used to accept any handle the caller held — so a
// stranger could point its own signal delivery at PID 1 and signal
// itself, and the terminate badge landing here was genuine in every
// bit. What makes these branches trustworthy is therefore in the
// KERNEL, not in this comment: binding a kernel notification to an
// endpoint now requires *owning* that endpoint (`ipc.ownedBy`), so a
// signal here comes only from our supervisor or our own group, a
// timer landing only from a timer we armed, and a child-exit notice
// only from a child we spawned. The buffered-message branch below is
// the one still carrying a stranger's bytes, and it is the one that
// authenticates its sender.
if (process.signalsFrom(got.badge)) |signals| {
if (signals.has(.terminate)) shutDown();
continue;
}
if (got.isTimer()) {
// The pending power subscription rides any timer landing: by the
// time one arrives, the bind's reply has left and the power
// service is serving again.
if (power_pending) {
power_pending = false;
subscribePower();
}
if (heartbeat_running) {
_ = logging.write("/system/services/init: heartbeat\n");
_ = time.timerOnce(supervision_endpoint, 1000);
}
continue;
}
if (got.isMessage()) {
// A buffered message: the only thing here an anonymous stranger
// can put in front of PID 1. Authenticated by sender, never by
// payload — see `onPowerEvent`.
onPowerEvent(got.senderTaskId(), receive[0..got.len]);
continue;
}
if (got.isChildExit()) {
restartChild(got.childProcessId());
continue;
}
continue; // anything else: keep waiting
}
if (build_options.serial and got.isTimer()) {
_ = logging.write("/system/services/init: heartbeat\n");
_ = time.timerOnce(supervision_endpoint, 1000);
continue;
}
if (got.isMessage() and got.len >= 2 and receive[0] == @intFromEnum(power_protocol.Operation.event)) {
// A power event (the only buffered messages init receives).
if (receive[1] == @intFromEnum(power_protocol.Event.power_button)) shutDown();
continue;
}
if (got.isChildExit()) {
restartChild(got.childProcessId());
continue;
}
// Anything else: keep waiting.
if (got.isNotification()) continue;
// The universal ping, answered by the empty reply. A ping may still carry
// a capability — the kernel installs one regardless of length — and this
// `continue` disposes of it through the turn's `defer`, which is exactly
// what it failed to do when the close lived in the branches.
if (got.len == 0) continue;
reply_len = serveRegistry(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
reply_capability = pending_capability;
pending_capability = null;
}
}
/// A buffered message claiming to be a power event.
///
/// **Privileged control traffic is authenticated by sender, never by content.**
/// init's registry endpoint is its supervision endpoint, and `fs_resolve` installs
/// a sendable handle to any mount's backend in *any* caller's table
/// (system/kernel/process.zig), so after P2 every ring-3 process holds a handle it
/// can `ipc_send` into. Two payload bytes were once enough to reach `shutDown()`
/// from here — which stops every service and parks PID 1 in its final sleep,
/// destroying the registry for the rest of the boot, and does it for any process
/// that cares to ask.
///
/// The sender's task id is the fix, because it is not the sender's to choose: the
/// kernel stamps it into the badge's low bits as it copies the message into the
/// ring. Init is the registry, so it knows exactly which task holds `power`, and
/// that task alone is believed. A provider that dies and rebinds moves the
/// authorization with the binding; a name nothing holds authorizes nobody. Task
/// ids are never reused, so even a dead provider's id cannot be inherited.
///
/// (One task, not one process: the ACPI service is single-threaded and publishes
/// from the same task that bound the name. A threaded provider would want its
/// leader compared instead — which is a change to make when one appears, not a
/// looser rule to leave lying around for it.)
fn onPowerEvent(sender: u32, payload: []const u8) void {
const authorized = power_task orelse {
std.log.info("ignored a power event from pid {d}: nothing holds /protocol/power", .{sender});
return;
};
if (sender != authorized) {
std.log.info("ignored a power event from pid {d}: /protocol/power is pid {d}", .{ sender, authorized });
return;
}
// Read as an envelope packet, never by byte offset: the kind IS the packet's
// verb, so a power event is decoded exactly the way every other event in the
// system is. A packet whose operation is not one of this protocol's events —
// anything else that lands in this mailbox — decodes to null and is dropped.
const kind = power_protocol.Protocol.eventOf(payload) orelse return;
if (kind == .power_button) shutDown();
}
/// A supervised boot service died. Find which one and restart it — unless it exited
/// cleanly (it chose to stop, e.g. a driver with no hardware) or has hit the crash-loop
/// cap. Reclaiming the dead process is already the kernel's job (docs/process-lifecycle.md
/// iron rule 1); init only decides whether to bring it back.
fn restartChild(id: u32) void {
// Whatever it served, it serves no longer: the name goes back before the
// replacement asks for it, so the restarted instance binds rather than
// colliding with its own corpse.
unbindTask(id);
if (shutting_down) return; // deaths during the stop sequence are expected, not crashes
for (services[0..service_count], 0..) |*service, i| {
if (child_ids[i] != id) continue;
@@ -209,22 +941,28 @@ fn restartChild(id: u32) void {
// An untracked child (e.g. the log-flush one-shot): nothing to restart.
}
/// Look up the power service and subscribe our endpoint (handed over as the
/// call's capability) so events arrive as buffered messages here.
/// Subscribe our endpoint (handed over as the call's capability) to the power
/// service, so events arrive as buffered messages here. init is the registry, so
/// it never resolves `/protocol/power` — it reads its own table, which is also
/// what makes this reachable at all: the subscription is armed by the bind that
/// put the endpoint there.
///
/// **This is the only place PID 1 blocks on another process, and it is the one
/// hazard the registrar has.** One thread serves both the namespace and this
/// call, so while it is outstanding init answers nobody: if the callee were
/// itself blocked asking init to resolve a name, the pair would never move. Two
/// things keep that from happening — the call is deferred to the next turn of
/// the loop (so the provider has its bind reply and is on its way to
/// `replyWait`), and the power provider resolves every name it needs *before* it
/// binds (system/services/acpi/acpi.zig, `manager_channel`). Any future service
/// init calls owes the same discipline.
fn subscribePower() void {
var handle: ?ipc.Handle = null;
var tries: u32 = 0;
while (handle == null and tries < 200) : (tries += 1) {
handle = ipc.lookup(.power);
if (handle == null) time.sleepMillis(20);
}
// A missing power service is not fatal — init proceeds to its heartbeat and
// a `terminate` signal still drives shutdown. Silent so the no-ramdisk init
// test's heartbeat marker is the next line written.
const h = handle orelse return;
const request = power_protocol.Subscribe{};
const handle = power_endpoint orelse return;
// The reserved `subscribe` verb: nothing but the header, with our own
// endpoint riding as the call's capability.
const header = envelope.Header{ .operation = envelope.operation_subscribe };
var reply: [power_protocol.message_maximum]u8 = undefined;
_ = ipc.callCap(h, std.mem.asBytes(&request), &reply, supervision_endpoint) catch {};
_ = ipc.callCap(handle, std.mem.asBytes(&header), &reply, supervision_endpoint) catch {};
}
/// The stop sequence: persist the log while storage is still up, then terminate
@@ -242,10 +980,12 @@ fn shutDown() void {
i -= 1;
if (child_ids[i] != 0) process.stop(child_ids[i], 2000, supervision_endpoint);
}
if (ipc.lookup(.power)) |h| {
const request = power_protocol.Shutdown{};
var reply: [power_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, std.mem.asBytes(&request), &reply) catch {};
if (power_endpoint) |h| {
var packet: [power_protocol.message_maximum]u8 = undefined;
if (power_protocol.Protocol.encodeRequest(.shutdown, 0, {}, &.{}, &packet)) |framed| {
var reply: [power_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, framed, &reply) catch {};
}
}
// If S5 did not take, init has nothing left to do but idle.
while (true) time.sleepMillis(1000);
+1 -1
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 = &.{ "input-client", "input-protocol", "ipc", "logging", "process", "service" },
.imports = &.{ "envelope", "input-protocol", "ipc", "logging", "service" },
});
b.installArtifact(exe);
}
+50 -106
View File
@@ -1,5 +1,5 @@
//! system/services/input — the user-space input service. Shipped in the initial_ramdisk,
//! spawned as a ring-3 process, and published under the well-known `input` service id. It
//! spawned as a ring-3 process, and bound at `/protocol/input`. It
//! is the fan-out point between **sources** (keyboard, mouse, and joystick/gamepad drivers)
//! and **subscribers** (any program that wants input): a source `publish`es an
//! `InputEvent`, and the service pushes it to every subscriber whose interest mask includes
@@ -17,126 +17,70 @@
//!
//! 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. P4c finished the job: the subscriber table, the
//! fan-out, and the dead-subscriber sweep are the harness's now
//! (`service.Subscribers`), so what is left here is what is actually about input — which
//! device class an event belongs to, and which classes a subscriber asked for. The sweep
//! that replaced the old prune is the one idiom the system uses everywhere: published
//! process-exit notifications, not a poll of the process list on every subscribe.
const std = @import("std");
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");
const envelope = @import("envelope");
/// 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.
const Subscriber = struct {
used: bool = false,
endpoint: ipc.Handle = 0,
task_id: u32 = 0,
/// Which device classes this subscriber wants (an OR of input_protocol.device_*). An event
/// is delivered only if its device's bit is set here.
device_mask: u32 = 0,
};
/// The subscriber side of the input contract: the table, the reserved `subscribe`
/// verb, the exit sweep, and the fan-out. One fan-out point per process, so the
/// handler context is empty.
const Subscriptions = service.Subscribers(input_protocol.Protocol, void);
var subscribers = [_]Subscriber{.{}} ** 8;
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// Drop any subscriber whose owning process is no longer alive, so its slot (and the
/// endpoint reference it holds) can be reused. Cheap and only run on subscribe — the async
/// `send` to a dead subscriber's orphaned endpoint is harmless (it just fills a queue no
/// one drains), so this is housekeeping, not correctness.
fn pruneDeadSubscribers() void {
var table: [32]process.ProcessDescriptor = undefined;
const total = process.processes(&table);
const count = @min(total, table.len);
for (&subscribers) |*sub| {
if (!sub.used) continue;
var alive = false;
for (table[0..count]) |descriptor| {
if (descriptor.id == sub.task_id) {
alive = true;
break;
}
}
if (!alive) sub.* = .{};
}
}
/// Register `endpoint` (owned by task `task_id`) to receive the device classes in
/// `device_mask`. Returns false if the subscriber table is full.
fn addSubscriber(endpoint: ipc.Handle, task_id: u32, device_mask: u32) bool {
for (&subscribers) |*sub| {
if (!sub.used) {
sub.* = .{ .used = true, .endpoint = endpoint, .task_id = task_id, .device_mask = device_mask };
return true;
}
}
return false;
}
/// 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.
/// Push `event` to every subscriber whose interest mask includes its device class. The
/// class is the packet's operation, so this picks *which event* to publish and the harness
/// frames it once for the whole fan-out — the mapping from device to class is the only part
/// of a broadcast that is this service's own.
fn broadcast(event: input_protocol.InputEvent) void {
const bytes = std.mem.asBytes(&event);
const bit = input_protocol.deviceBit(event.device);
for (&subscribers) |*sub| {
if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, bytes);
const class = input_protocol.deviceBit(event.device);
switch (input_protocol.eventOfDevice(event.device) orelse return) { // no class wants it
.keyboard => Subscriptions.publishClass(.keyboard, 0, event.asKeyboard() orelse return, class),
.mouse => Subscriptions.publishClass(.mouse, 0, event.asMouse() orelse return, class),
.joystick => Subscriptions.publishClass(.joystick, 0, event.asJoystick() orelse return, class),
}
}
/// Handle one request. `got` carries the sender badge (a task id) and, for subscribe, the
/// subscriber's endpoint capability in `got.cap`. Writes a `Reply` into `out` and returns
/// its length.
fn handle(message: []const u8, got: ipc.Received, out: []u8) 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;
}
};
fn onPublish(_: void, invocation: Invocation(input_protocol.InputEvent), _: Answer(void)) isize {
broadcast(invocation.request);
return 0;
}
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]);
/// `subscribe` and `unsubscribe` are absent on purpose: the harness answers both.
const handlers = Subscriptions.Handlers{ .publish = onPublish };
switch (@as(input_protocol.Operation, @enumFromInt(request.operation))) {
.subscribe => {
const endpoint = got.cap 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
return reply.write(out, 0);
},
.publish => {
broadcast(request.event);
return reply.write(out, 0);
},
}
fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
return Subscriptions.dispatch({}, handlers, message, sender, arrived, out);
}
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 (!ipc.register(.input, endpoint)) {
_ = logging.write("/system/services/input: register failed\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);
// 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);
}
service.run(input_protocol.message_maximum, .{
.service = "input",
.init = initialise,
.on_message = onMessage,
.subscribers = Subscriptions.hooks,
});
}
+2 -2
View File
@@ -99,11 +99,11 @@ fn initialise(harness_endpoint: ipc.Handle) bool {
}
/// The logger serves no protocol; the ping is answered by the harness.
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = message;
_ = reply;
_ = sender;
_ = capability;
_ = arrived; // nothing here takes a capability: the harness closes what arrives
return 0;
}
+78
View File
@@ -658,6 +658,18 @@ CASES = [
"timeout": 150,
"expect": r"fat-test: rename ok",
"fail": r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"},
# P4c: badge-scoped per-client ids. Two processes of one fixture, on the same
# boot: the owner holds a FAT node id and a compositor layer id, the intruder
# names both and must be refused — while its own node and layer, and the
# owner's after the attempt, keep working. Every refusal is paired with a
# control, so a provider that refused everything (or honoured everything)
# fails this case rather than passing it. Reuses the fat-mount build.
{"name": "badge-scope",
"build_case": "fat-mount",
"smp": 4,
"timeout": 150,
"expect": r"(?s)(?=.*badge-scope-test: ok)(?=.*badge-scope-test: owner intact)",
"fail": r"badge-scope-test: FAILED|DANOS-TEST-RESULT: FAIL"},
# Phase 2d: filesystem timestamps — a freshly-created file's mtime is a real
# current wall-clock time (stamped from the RTC), read back through stat.
{"name": "fat-mtime",
@@ -846,6 +858,72 @@ CASES = [
{"name": "input",
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# The protocol registry (docs/os-development/protocol-namespace.md): init serves
# /protocol, and the fixture drives the registrar's whole contract — an ungranted
# bind refused (-EPERM), the kernel's reserved prefix holding against mount and
# unmount, a live owner's name refused (-EBUSY), and a killed provider's channel
# failing while a re-resolve reaches the restarted instance. Each step prints its
# own line, so a failure says which rule broke, not merely that one did.
# Three of the steps are the registrar's security contract, and each fails
# catastrophically rather than quietly if it regresses: a forged power event
# posted to PID 1's mailbox must not shut the machine down (a regression ends
# the boot), capability-carrying zero-length pings must not consume PID 1's
# handle table (a regression makes every later bind impossible), and a granted
# binary spawned by an unauthorized task must be refused while the same binary
# spawned by an authorized one is not (the laundering deputy).
{"name": "protocol-registry",
"expect": r"(?s)(?=.*protocol-registry: ungranted bind refused)"
r"(?=.*protocol-registry: /protocol reserved)"
r"(?=.*protocol-registry: forged power event ignored)"
r"(?=.*protocol-registry: capability-carrying pings did not exhaust the registrar)"
r"(?=.*protocol-registry: collision refused)"
r"(?=.*protocol-registry: dead channel refused)"
r"(?=.*protocol-registry: restarted provider reached)"
r"(?=.*protocol-registry: laundering deputy refused)"
r"(?=.*protocol-registry: foreign signal binding refused)"
r"(?=.*protocol-registry: foreign timer and exit binding refused)(?=.*protocol-registry: foreign receive refused)"
r"(?=.*protocol-registry: capability-carrying pings did not exhaust the harness)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-registry: FAIL"},
# Restriction stage one (docs/os-development/protocol-namespace.md): the
# registrar checks `open` against /system/configuration/protocol.csv, and a
# caller with no grant gets the same answer as a caller naming a contract
# nobody bound. The scenario boots /protocol plus the input service, so the
# forbidden name is genuinely BOUND — the fixture reads the namespace listing
# to prove it — and then compares the refusal with an unbound name field by
# field: status, node, payload length, the whole reply packet, and the
# presence of a capability. All three failure shapes (refused-and-bound,
# granted-and-unbound, neither) must collapse into one answer.
{"name": "protocol-denied",
"expect": r"(?s)(?=.*protocol-denied: granted open succeeded)"
r"(?=.*protocol-denied: ungranted open refused as absent)"
r"(?=.*protocol-denied: refusal is indistinguishable from absence)"
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. The other six the fixture knows —
# vfs, block, scanout, device-manager, power and usb-transfer — need provider
# chains this case does not boot (the last three all arrive with the device
# manager, i.e. with the whole driver tree, whose timing would make this
# fixture's one namespace snapshot a boot race). It names them as unchecked
# rather than skipping them quietly, and the fat-mount / usb-storage /
# virtio-gpu / device-list / driver-restart / pci-scan / usb-* /
# orderly-shutdown 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").
@@ -0,0 +1,216 @@
//! test/system/services/badge-scope-test — the guessable-id probe. Two providers
//! hand small integers to a client and then take them back from anyone who says
//! the number: the FAT server's node ids (a table of thirty-two) and the
//! compositor's layer ids (a table of sixteen). P4c scoped both to the badge that
//! opened them (docs/os-development/protocol-namespace.md — *handles must be
//! scoped per client, validated against the badge*), and this fixture is the
//! proof.
//!
//! It runs as two processes of the same binary, because the hole is a
//! *cross-client* one and a single process cannot demonstrate it:
//!
//! - the **owner** (no arguments — the scenario spawns this one) opens a file
//! on the volume and creates a layer, keeps both, and spawns
//! - the **intruder** (the two ids as argv), which opens a file and a layer of
//! its own and then names the owner's.
//!
//! Every refusal is paired with the same operation on the intruder's own id, so
//! the case cannot pass against a provider that refuses everything; and the owner
//! re-uses both ids after the intruder has finished — including after its attempt
//! to *close* the file node — so the case cannot pass against a provider that
//! honoured the intruder and merely reported failure. The pass marker is
//! `badge-scope-test: ok` from the intruder plus `badge-scope-test: owner intact`
//! from the owner.
//!
//! What it cannot check: which id the intruder was refused *for*. A refusal is
//! deliberately identical to "no such id" — that is the discipline being tested —
//! so the fixture proves the boundary by construction (an id it was told about by
//! its parent, which is holding it) rather than by reading anything back.
const std = @import("std");
const display = @import("display-client");
const fs = @import("file-system");
const ipc = @import("ipc");
const logging = @import("logging");
const process = @import("process");
const time = @import("time");
/// A scratch file on the volume, so the node the intruder tries to write through
/// is one nothing else reads. (A foreign write that *succeeded* would prove the
/// bug — it must not also damage the boot volume proving it.)
const held_path = "/volumes/usb/BADGE.TXT";
const held_contents = "held";
fn line(comptime format: []const u8, arguments: anytype) void {
var buffer: [192]u8 = undefined;
_ = logging.write(std.fmt.bufPrint(&buffer, format, arguments) catch return);
}
/// The fat server mounts /volumes/usb only after the whole USB storage chain is
/// up, and both instances race it.
fn waitForVolume() bool {
var tries: u32 = 0;
while (tries < 1400) : (tries += 1) {
if (fs.openDirectory("/volumes/usb")) |opened| {
var directory = opened;
directory.close();
return true;
}
time.sleepMillis(50);
}
return false;
}
pub fn main(init: process.Init) void {
if (init.arguments.get(1)) |node_text| {
const layer_text = init.arguments.get(2) orelse {
_ = logging.write("badge-scope-test: FAILED (intruder without a layer id)\n");
return;
};
const node = std.fmt.parseInt(u64, node_text, 10) catch return;
const layer = std.fmt.parseInt(u32, layer_text, 10) catch return;
intrude(node, layer);
return;
}
own();
}
// --- the owner ---------------------------------------------------------------
fn own() void {
if (!waitForVolume()) {
_ = logging.write("badge-scope-test: FAILED (/volumes/usb never became available)\n");
return;
}
var held = fs.open(held_path, .{ .create = true, .truncate = true }) orelse {
_ = logging.write("badge-scope-test: FAILED (owner could not create its file)\n");
return;
};
if ((held.writeAll(held_contents) orelse 0) != held_contents.len) {
_ = logging.write("badge-scope-test: FAILED (owner could not write its file)\n");
return;
}
const layer = display.createLayer(240, 40, 32, 32, 3) orelse {
_ = logging.write("badge-scope-test: FAILED (owner could not create a layer)\n");
return;
};
_ = layer.fill(0, 0, 32, 32, display.color(0x20, 0x80, 0xC0));
line("badge-scope-test: owner holds node {d} and layer {d}\n", .{ held.node, layer.id });
// The intruder is told both ids outright: guessing them is not what is being
// tested (they are small integers — a prober would simply enumerate), and a
// fixture that had to search would be asserting on a race instead of on the
// rule.
var node_text: [24]u8 = undefined;
var layer_text: [12]u8 = undefined;
const arguments = [_][]const u8{
std.fmt.bufPrint(&node_text, "{d}", .{held.node}) catch return,
std.fmt.bufPrint(&layer_text, "{d}", .{layer.id}) catch return,
};
const endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("badge-scope-test: FAILED (owner has no exit endpoint)\n");
return;
};
const intruder = process.spawnSupervised("badge-scope-test", &arguments, endpoint) orelse {
_ = logging.write("badge-scope-test: FAILED (could not spawn the intruder)\n");
return;
};
var receive: [8]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, &.{}, &receive, null);
if (got.isChildExit() and got.childProcessId() == intruder) break;
}
// Both ids must still be the owner's, and still work — the half of the proof
// the intruder cannot give, because a provider that had honoured its close or
// its destroy would have answered it exactly as one that refused.
held.seekTo(0);
var buffer: [16]u8 = undefined;
const read = held.read(&buffer) orelse 0;
const node_intact = read == held_contents.len and std.mem.eql(u8, buffer[0..read], held_contents);
const layer_intact = layer.fill(0, 0, 32, 32, display.color(0x20, 0xC0, 0x80));
if (node_intact and layer_intact) {
_ = logging.write("badge-scope-test: owner intact\n");
} else {
line("badge-scope-test: FAILED (owner lost its ids: node={} layer={})\n", .{ node_intact, layer_intact });
}
_ = layer.destroy();
held.close();
}
// --- the intruder -------------------------------------------------------------
fn intrude(foreign_node: u64, foreign_layer: u32) void {
if (!waitForVolume()) {
_ = logging.write("badge-scope-test: FAILED (/volumes/usb never became available)\n");
return;
}
const node_verdict = probeNode(foreign_node);
const layer_verdict = probeLayer(foreign_layer);
if (node_verdict and layer_verdict) {
_ = logging.write("badge-scope-test: ok\n");
} else {
_ = logging.write("badge-scope-test: FAILED\n");
}
}
/// The FAT half: the intruder's own node works, the owner's does not.
fn probeNode(foreign: u64) bool {
var mine = fs.open(held_path, .{}) orelse {
_ = logging.write("badge-scope-test: FAILED (intruder could not open its own file)\n");
return false;
};
defer mine.close();
if (mine.backend == null or mine.node == foreign) {
line("badge-scope-test: FAILED (intruder's node {d} is not distinct from {d})\n", .{ mine.node, foreign });
return false;
}
var buffer: [16]u8 = undefined;
const own_read = (mine.read(&buffer) orelse 0) == held_contents.len;
// The same backend channel, the same verbs, one different integer. Every one
// of these worked before the owner check went in.
var forged = fs.File{ .node = foreign, .backend = mine.backend };
const read_refused = forged.read(&buffer) == null;
const status_refused = forged.attributes() == null;
const write_refused = forged.write("intruded") == null;
forged.close(); // must not release the owner's node — the owner proves that after we exit
mine.seekTo(0);
const own_read_again = (mine.read(&buffer) orelse 0) == held_contents.len;
const ok = own_read and own_read_again and read_refused and status_refused and write_refused;
line("badge-scope-test: node own={} read_refused={} status_refused={} write_refused={} own_again={}\n", .{
own_read, read_refused, status_refused, write_refused, own_read_again,
});
return ok;
}
/// The display half: the intruder's own layer obeys it, the owner's ignores it.
fn probeLayer(foreign: u32) bool {
const mine = display.createLayer(200, 40, 32, 32, 4) orelse {
_ = logging.write("badge-scope-test: FAILED (intruder could not create a layer)\n");
return false;
};
if (mine.id == foreign) {
line("badge-scope-test: FAILED (intruder's layer {d} is not distinct)\n", .{mine.id});
return false;
}
const own_fill = mine.fill(0, 0, 32, 32, display.color(0xC0, 0x40, 0x40));
const own_configure = mine.configure(200, 80, 4, true);
const forged = display.Layer{ .id = foreign };
const fill_refused = !forged.fill(0, 0, 32, 32, display.color(0xFF, 0, 0));
const configure_refused = !forged.configure(0, 0, 9, false);
const destroy_refused = !forged.destroy();
const own_still = mine.fill(0, 0, 32, 32, display.color(0x40, 0xC0, 0x40));
_ = mine.destroy();
const ok = own_fill and own_configure and own_still and fill_refused and configure_refused and destroy_refused;
line("badge-scope-test: layer own={} fill_refused={} configure_refused={} destroy_refused={} own_again={}\n", .{
own_fill, fill_refused, configure_refused, destroy_refused, own_still,
});
return ok;
}
@@ -0,0 +1,15 @@
//! The badge-scope-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 = "badge-scope-test",
.root_source_file = b.path("badge-scope-test.zig"),
.imports = &.{ "display-client", "file-system", "ipc", "logging", "process", "time" },
});
b.installArtifact(exe);
}
@@ -0,0 +1,17 @@
.{
.name = .badge_scope_test,
.version = "0.0.0",
.fingerprint = 0x92ab15512c4e5b49, // 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" },
// client: the compositor half of the probe talks to /protocol/display
// through the same client library every other display client uses.
.client = .{ .path = "../../../../library/client" },
},
.paths = .{""},
}
+1 -1
View File
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "crash-test",
.root_source_file = b.path("crash-test.zig"),
.imports = &.{ "device-manager-protocol", "driver", "ipc", "logging", "process", "time" },
.imports = &.{ "channel", "device-manager-protocol", "driver", "ipc", "logging", "process", "time" },
});
b.installArtifact(exe);
}
+12 -3
View File
@@ -7,6 +7,7 @@
//! binary), it exits silently so it cannot derange other tests.
const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const time = @import("time");
@@ -29,13 +30,21 @@ pub fn main(init: process.Init) void {
var manager: ?ipc.Handle = null;
var tries: u32 = 0;
while (manager == null and tries < 100) : (tries += 1) {
manager = ipc.lookup(.device_manager);
manager = channel.openEndpoint("device-manager");
if (manager == null) time.sleepMillis(20);
}
const h = manager orelse return;
const hello = device_manager_protocol.Hello{ .role = @intFromEnum(device_manager_protocol.Role.device), .device_id = assigned };
// The assigned device is the packet's target, the manager's object addressing.
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(
.hello,
assigned,
.{ .role = @intFromEnum(device_manager_protocol.Role.device) },
&.{},
&packet,
) orelse return;
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, std.mem.asBytes(&hello), &reply) catch return;
_ = ipc.call(h, framed, &reply) catch return;
_ = logging.write("crash-test: faulting now\n");
const poison: *volatile u32 = @ptrFromInt(0xdead0000);
+1 -1
View File
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "device-list",
.root_source_file = b.path("device-list.zig"),
.imports = &.{ "device-manager-protocol", "ipc", "logging", "time" },
.imports = &.{ "channel", "device-manager-protocol", "envelope", "ipc", "logging", "time" },
});
b.installArtifact(exe);
}
@@ -5,6 +5,8 @@
//! device_* system call.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const time = @import("time");
const logging = @import("logging");
@@ -19,7 +21,7 @@ pub fn main() void {
var manager: ?ipc.Handle = null;
var tries: u32 = 0;
while (manager == null and tries < 200) : (tries += 1) {
manager = ipc.lookup(.device_manager);
manager = channel.openEndpoint("device-manager");
if (manager == null) time.sleepMillis(20);
}
const h = manager orelse {
@@ -28,36 +30,41 @@ pub fn main() void {
};
// The snapshot — polled briefly, because at boot the bus drivers may still
// be scanning: an empty first answer usually just means "too early".
// be scanning: an empty first answer usually just means "too early". This is
// the envelope's reserved `enumerate` verb, so the request is nothing but a
// header and the answer is one `ChildEntry` per record in the reply's tail —
// how many arrived is the reply's own length, which is why no count header
// says it a second time.
const Entry = device_manager_protocol.ChildEntry;
const enumerate = envelope.Header{ .operation = envelope.operation_enumerate };
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
var count: u32 = 0;
var length: usize = 0;
var count: usize = 0;
tries = 0;
while (tries < 20) : (tries += 1) {
const request = device_manager_protocol.Enumerate{};
length = ipc.call(h, std.mem.asBytes(&request), &reply) catch 0;
if (length >= @sizeOf(device_manager_protocol.EnumerateReply)) {
count = std.mem.bytesToValue(device_manager_protocol.EnumerateReply, reply[0..@sizeOf(device_manager_protocol.EnumerateReply)]).count;
if (count != 0) break;
const length = ipc.call(h, std.mem.asBytes(&enumerate), &reply) catch 0;
if (envelope.statusOf(reply[0..length])) |status| {
if (status.status == 0) {
const carried = @min(@as(usize, status.len), length - envelope.prefix_size);
count = carried / @sizeOf(Entry);
if (count != 0) break;
}
}
time.sleepMillis(100);
}
writeLine("device-list: {d} devices\n", .{count});
var offset: usize = @sizeOf(device_manager_protocol.EnumerateReply);
var index: u32 = 0;
while (index < count and offset + @sizeOf(device_manager_protocol.ChildEntry) <= length) : (index += 1) {
const entry = std.mem.bytesToValue(device_manager_protocol.ChildEntry, reply[offset..][0..@sizeOf(device_manager_protocol.ChildEntry)]);
for (0..count) |index| {
const entry = std.mem.bytesToValue(Entry, reply[envelope.prefix_size + index * @sizeOf(Entry) ..][0..@sizeOf(Entry)]);
writeLine("device-list: device {d} port {d} identity {d}\n", .{ entry.parent, entry.bus_address, entry.identity });
offset += @sizeOf(device_manager_protocol.ChildEntry);
}
// The subscription: our endpoint rides as the call's capability; events
// arrive as buffered messages carrying the same structs the bus sends.
// The subscription — the reserved `subscribe` verb: our endpoint rides as
// the call's capability, and events arrive as buffered packets carrying the
// same structs the bus drivers send, under the events' own numbering.
const endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("device-list: no endpoint\n");
return;
};
const subscribe = device_manager_protocol.Subscribe{};
const subscribe = envelope.Header{ .operation = envelope.operation_subscribe };
_ = ipc.callCap(h, std.mem.asBytes(&subscribe), &reply, endpoint) catch {
_ = logging.write("device-list: subscribe failed\n");
return;
@@ -67,19 +74,18 @@ pub fn main() void {
var receive: [device_manager_protocol.message_maximum]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, &.{}, &receive, null);
if (!got.isMessage() or got.len < 1) continue;
switch (receive[0]) {
@intFromEnum(device_manager_protocol.Operation.child_added) => {
if (got.len < device_manager_protocol.child_added_size) continue;
const event = std.mem.bytesToValue(device_manager_protocol.ChildAdded, receive[0..device_manager_protocol.child_added_size]);
writeLine("device-list: added (device {d} port {d})\n", .{ event.parent, event.bus_address });
if (!got.isMessage()) continue;
const packet = receive[0..got.len];
const event = device_manager_protocol.Protocol.eventOf(packet) orelse continue;
switch (event) {
.child_added => {
const added = device_manager_protocol.Protocol.decodeEvent(.child_added, packet) orelse continue;
writeLine("device-list: added (device {d} port {d})\n", .{ added.parent, added.bus_address });
},
@intFromEnum(device_manager_protocol.Operation.child_removed) => {
if (got.len < device_manager_protocol.child_removed_size) continue;
const event = std.mem.bytesToValue(device_manager_protocol.ChildRemoved, receive[0..device_manager_protocol.child_removed_size]);
writeLine("device-list: removed (device {d} port {d})\n", .{ event.parent, event.bus_address });
.child_removed => {
const removed = device_manager_protocol.Protocol.decodeEvent(.child_removed, packet) orelse continue;
writeLine("device-list: removed (device {d} port {d})\n", .{ removed.parent, removed.bus_address });
},
else => {},
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "process-test",
.root_source_file = b.path("process-test.zig"),
.imports = &.{ "ipc", "logging", "process", "service", "time" },
.imports = &.{ "channel", "ipc", "logging", "process", "service", "time" },
});
b.installArtifact(exe);
}
@@ -17,6 +17,7 @@
//! binary bare), it exits silently so it cannot derange other tests' output.
const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -55,9 +56,9 @@ fn awaitChildExit(endpoint: ipc.Handle) u32 {
/// The harness-run child of the signals test: echoes requests, logs the two
/// signals it handles. Terminate makes run() return, and returning from main is
/// the clean exit the parent reads as ExitReason.exited.
fn echo(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
fn echo(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = sender;
_ = capability;
_ = arrived;
const n = @min(message.len, reply.len);
@memcpy(reply[0..n], message[0..n]);
return n;
@@ -82,10 +83,10 @@ fn signalRun() void {
var service_handle: ?ipc.Handle = null;
var tries: u32 = 0;
while (service_handle == null and tries < 200) : (tries += 1) {
service_handle = ipc.lookup(.input);
service_handle = channel.openEndpoint("test/process");
if (service_handle == null) time.sleepMillis(20);
}
const h = service_handle orelse fail("service child never registered");
const h = service_handle orelse fail("service child never bound its contract");
// The universal ping: a zero-length call answered zero-length by the harness.
var reply: [16]u8 = undefined;
@@ -125,9 +126,10 @@ pub fn main(init: process.Init) void {
while (true) time.sleepMillis(500);
}
if (std.mem.eql(u8, role, "service")) {
// Borrowed well-known id: the input service is not part of this scenario.
// A fixture's own contract, under the /protocol/test subtree every
// /test/ binary is granted (docs/os-development/protocol-namespace.md).
service.run(64, .{
.service = .input,
.service = "test/process",
.on_message = echo,
.on_reload = onReload,
.on_terminate = onTerminate,
@@ -0,0 +1,31 @@
//! 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",
"device-manager-protocol",
"display-protocol",
"envelope",
"file-system",
"input-protocol",
"ipc",
"logging",
"power-protocol",
"process",
"scanout-protocol",
"time",
"usb-transfer-protocol",
"vfs-protocol",
},
});
b.installArtifact(exe);
}
@@ -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 = .{""},
}
@@ -0,0 +1,348 @@
//! 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.** 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 six contracts in the table
//! 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);
//! - `device-manager`, `power` and `usb-transfer` — the three P4b rebased. All
//! three come with the device manager: it *is* the first, it spawns the
//! discovery service that binds the second, and the xHCI driver it spawns
//! binds the third. So booting a provider for any one of them means booting
//! the whole driver tree here.
//!
//! That is the reason this scenario stays at two providers rather than five or
//! eight. It is not only the cost of booting half the system to send two
//! packets: this fixture takes **one snapshot** of `/protocol` and checks what
//! is in it, so a scenario whose bound set depends on how far a driver tree got
//! by that instant would make the case's own summary a boot race. What proves
//! the six instead is the scenarios that already drive them end to end —
//! `fat-mount`, `usb-storage`, `virtio-gpu`, and for the P4b three the
//! `device-list`, `driver-restart`, `pci-scan`, `usb-*`, `power-button` and
//! `orderly-shutdown` cases, every one of which is a live conversation over
//! these wires.
//!
//! All six sit in the table below regardless, so a scenario that binds one gets
//! it conformance-checked without this file being edited — and every run prints,
//! by name, the ones it found no provider for.
//!
//! 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 device_manager_protocol = @import("device-manager-protocol");
const display_protocol = @import("display-protocol");
const input_protocol = @import("input-protocol");
const power_protocol = @import("power-protocol");
const scanout_protocol = @import("scanout-protocol");
const usb_transfer_protocol = @import("usb-transfer-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`. A name listed by `/protocol` that
/// is absent from here is reported and left alone rather than probed: a
/// hand-numbered provider would read operation 0 as one of its own verbs, so
/// asking it for `describe` would *do* something. Only `ps2-bus` is still in
/// that state today.
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
// The three P4b rebased. Each needs the device manager (and, for the last
// two, what the device manager starts), which is more than this scenario
// boots — see the header.
contractOf(device_manager_protocol.Protocol, false),
contractOf(power_protocol.Protocol, false), // the discovery service
contractOf(usb_transfer_protocol.Protocol, false), // the xHCI bus driver
};
/// 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();
}
@@ -0,0 +1,15 @@
//! The protocol-denied-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-denied-test",
.root_source_file = b.path("protocol-denied-test.zig"),
.imports = &.{ "channel", "envelope", "file-system", "ipc", "logging", "process", "time", "vfs-protocol" },
});
b.installArtifact(exe);
}
@@ -0,0 +1,19 @@
.{
.name = .protocol_denied_test,
.version = "0.0.0",
.fingerprint = 0xab37a6fa7116698f, // 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 errno the registrar answers a refused open with;
// vfs-protocol: the request and reply this fixture compares byte for
// byte, which is why it speaks the wire itself instead of using the
// Channel client.
.protocol = .{ .path = "../../../../library/protocol" },
},
.paths = .{""},
}
@@ -0,0 +1,279 @@
//! protocol-denied-test — restriction stage one's own test fixture
//! (docs/os-development/protocol-namespace.md, "Restriction: per-process
//! namespaces, not ACLs"). One binary, one role, driven by the
//! `protocol-denied` kernel case:
//!
//! - `protocol-denied-test run` — the driver, and the assertions:
//! 1. a contract this binary IS granted opens: the reply says success and
//! carries a capability — the channel itself. The control comes first
//! and is repeated last, because every refusal below would read exactly
//! the same against a registrar that had simply stopped opening things;
//! 2. a contract this binary is NOT granted is refused — and `/protocol`'s
//! own listing is read first to prove the name is genuinely BOUND, so
//! the refusal is a policy decision and not an accident of boot order;
//! 3. the refusal is **indistinguishable from a name that does not exist**.
//! The fixture asks for a name nothing ever bound and compares the two
//! answers field by field — status, node, payload length, the whole
//! reply packet byte for byte, and the presence of a capability. That
//! collapse is the model, not a nicety: "permission denied" and "not
//! found" are one answer, so an open can never be used as an oracle for
//! what exists outside a process's view, and stage two's supervisor can
//! refuse, park for a human, or substitute a fake with the child unable
//! to tell which happened;
//! 4. the third shape — neither granted nor bound — answers identically
//! too, so all three collapse into one rather than two.
//!
//! **What this fixture deliberately does not claim.** Two channels are outside
//! what a ring-3 client can honestly assert:
//!
//! - *The registrar's log.* `klog_read` is ungated, so a line written on one
//! branch and not the other would be readable here — but the ring carries
//! every task's output, so searching it for a contract name proves nothing
//! either way (the input service prints "input:" lines of its own). The
//! guarantee is made at the source instead: `onOpen` in
//! system/services/init/init.zig writes nothing on any branch, and says why.
//! - *Timing.* The difference worth measuring — a syscall or a serial write on
//! one branch — is milliseconds, but this fixture shares four cores and a
//! serial line with a booting system, so a measurement here would be noise
//! wearing an assertion's clothes. `onOpen` asks both questions on every
//! open, whatever the first one answers; that is the claim, and it is a claim
//! about the code, checked by reading it.
//!
//! Prints `protocol-denied: ok` on success, or a `protocol-denied: 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 vfs_protocol = @import("vfs-protocol");
/// The contract this fixture provides and then reaches — under `/protocol/test`,
/// the subtree every `/test/` binary is granted. Binding it ourselves keeps the
/// granted case to one process: the registry does not know or care that the
/// provider on the other end of the channel is us.
const granted_contract = "test/denied-probe";
/// A contract this fixture is NOT granted and that the case makes sure IS bound:
/// the input service claims it, and only `input-source` and `input-test` are
/// named against it in /system/configuration/protocol.csv.
const forbidden_contract = "input";
/// A contract this fixture IS granted (the `test/*` subtree) and that nothing
/// ever binds. The comparison partner: refusal must look like this.
const absent_contract = "test/never-bound";
/// Neither granted nor bound. The third shape, so the collapse is into one
/// answer rather than two.
const forbidden_and_absent_contract = "display";
fn fail(step: []const u8) noreturn {
_ = logging.write("protocol-denied: FAIL ");
_ = logging.write(step);
_ = logging.write("\n");
process.exit(1);
}
// --- talking to the registrar directly --------------------------------------
//
// `channel.openEndpoint` folds every failure into null, which is exactly right
// for a client and useless here: the whole assertion is about the *shape* of the
// answer, so this fixture speaks the vfs protocol to the registry itself and
// keeps every byte that came back.
/// One `open` answer, kept whole.
const Answer = struct {
/// Bytes the registrar replied with — the reply packet's length is itself a
/// channel, so it is compared like any other field.
length: usize = 0,
packet: [vfs_protocol.message_maximum]u8 = .{0} ** vfs_protocol.message_maximum,
/// 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'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];
}
};
/// 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.
fn registryEndpoint() ?ipc.Handle {
// Patiently, for the same reason `bindPatiently` is patient: the kernel test
// harness starts the registrar and this fixture together, so a first resolve
// can land in the window before init has mounted `/protocol` at all. An
// absent mount is a boot race and worth waiting out; a registrar that
// answers has decided, and that answer is what the assertions below weigh.
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;
}
/// The cadence `channel.bindPatiently` uses, for the same window.
const resolve_attempts: u32 = 200;
const resolve_retry_ms: u64 = 20;
/// 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, framed, &answer.packet, null) catch return null;
answer.length = got.len;
answer.capability = got.cap != null;
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);
return answer;
}
/// `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, .{ .flags = 0 }, name) orelse fail("the registry stopped answering");
}
/// Whether `/protocol` currently lists `name`. The namespace is browsable on
/// purpose (docs/os-development/protocol-namespace.md: `readdir` lists protocol
/// nodes like any others, so the tree stays diagnosable), and that is what lets
/// this fixture prove a refused name is really there — without it, "refused"
/// and "not bound yet" would be the same observation and the test would assert
/// nothing.
fn listed(registry: ipc.Handle, name: []const u8) bool {
var cursor: u64 = 0;
while (cursor < 64) : (cursor += 1) {
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;
}
return false;
}
/// Wait until `/protocol` lists `name` — the providers this case needs come up
/// alongside the fixture, and racing them would make the assertions meaningless
/// rather than merely flaky.
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);
}
_ = logging.write("protocol-denied: FAIL /protocol never listed ");
_ = logging.write(name);
_ = logging.write("\n");
process.exit(1);
}
/// 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.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");
// Provide the granted contract ourselves. `bindPatiently` waits out a
// registry that has not mounted `/protocol` yet, which is the one thing
// worth retrying — a registrar that answered has decided.
const provider = ipc.createIpcEndpoint() orelse fail("create the provider endpoint");
if (!channel.bindPatiently(granted_contract, provider)) fail("binding a granted contract was refused");
// Both names must be bound before anything is asked of them, or the
// comparison below would be between two boot races.
awaitListed(registry, granted_contract);
awaitListed(registry, forbidden_contract);
// 1. The control. A granted, bound contract opens: success, and the
// capability that IS the channel.
const allowed = openContract(registry, granted_contract);
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.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");
// 3. The point of the whole fixture. A name this binary IS granted and that
// nothing has ever bound, answered by the same registrar in the same
// breath — and every caller-visible field of the two answers is the same.
const absent = openContract(registry, absent_contract);
expectIdentical("a refused open differed from a nonexistent name", refused, absent);
// 4. And the third shape, so the two reasons collapse into one answer rather
// than into two that happen to match: neither granted nor bound.
const neither = openContract(registry, forbidden_and_absent_contract);
expectIdentical("a refused-and-absent open differed from the others", refused, neither);
_ = logging.write("protocol-denied: refusal is indistinguishable from absence\n");
// 5. The control again, after the refusals: the registrar is still opening
// 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.status.status != 0 or !again.capability) fail("the granted contract stopped opening");
_ = logging.write("protocol-denied: 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();
}
@@ -0,0 +1,15 @@
//! The protocol-registry-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-registry-test",
.root_source_file = b.path("protocol-registry-test.zig"),
.imports = &.{ "channel", "envelope", "file-system", "ipc", "logging", "power-protocol", "process", "service", "time" },
});
b.installArtifact(exe);
}
@@ -0,0 +1,17 @@
.{
.name = .protocol_registry_test,
.version = "0.0.0",
.fingerprint = 0x76dd7e3bd3b6e2cd, // 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 errno vocabulary the registry refuses with;
// power-protocol: the exact event bytes the forged-shutdown probe posts.
.protocol = .{ .path = "../../../../library/protocol" },
},
.paths = .{""},
}
@@ -0,0 +1,462 @@
//! protocol-registry-test — the registrar's own test fixture
//! (docs/os-development/protocol-namespace.md). One binary, four roles picked by
//! argv, driven by the `protocol-registry` kernel case:
//!
//! - `protocol-registry-test provider <mark>` — binds `/protocol/test/registry`
//! and answers every request with its one-byte `<mark>`, so a client can tell
//! *which instance* it reached.
//! - `protocol-registry-test deputy` / `... launder` — the two halves of the
//! laundering-deputy probe (assertion 7 below).
//! - `protocol-registry-test run` — the driver, and the assertions:
//! 1. a bind of a name this binary is not granted is refused, `-EPERM`;
//! 2. the kernel's reserved prefix holds: nothing may mount at or under
//! `/protocol`, and nobody may unmount it;
//! 3. a **forged power event** posted straight into the registry endpoint
//! does not shut the machine down — the registrar answers privileged
//! control traffic on the sender's kernel-stamped identity, never on two
//! bytes of payload. The system surviving to the next line is the
//! assertion, and it is the strongest one available: a regression takes
//! the whole boot down;
//! 4. zero-length calls carrying a capability do not consume PID 1's handle
//! table — after sixty-four of them a legitimate bind still succeeds;
//! 5. a bind colliding with a **live** owner is refused, `-EBUSY`;
//! 6. killing the provider makes the cached channel fail (`-EPEER`), and a
//! client that re-resolves reaches the **restarted** instance — the
//! restart story falling out of the naming layer, with no reconnect verb
//! anywhere;
//! 7. the laundering deputy is refused: the *same binary* under the *same
//! supervisor name* binds when its supervisor is a task the kernel
//! started, and is refused one hop further down, where the supervisor is
//! a task nobody authorized. The pair is the point — one alone would pass
//! for reasons that have nothing to do with the rule under test;
//! 8. a **forged terminate signal** does not reach PID 1 either: the kernel
//! refuses to bind this process's signal delivery to an endpoint it does
//! not own, so "bind init's endpoint, then signal yourself" — which needs
//! no forgery at all, only two ungated syscalls — cannot happen;
//! 9. the same for the other kernel notifications: a timer and an exit
//! subscription may only be armed on one's own endpoint;
//! 10. the handle-table storm of assertion 4, aimed at a **harness-run**
//! service instead of PID 1 — the loop every other service in the system
//! runs — after which a capability-passing operation still works.
//!
//! Assertions 8–10 each pair a refusal with a control (the same operation on an
//! endpoint this process created, which must succeed), because a refusal alone
//! would pass just as happily against a kernel that refused everything.
//!
//! Prints `protocol-registry: ok` on success, or a `protocol-registry: 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 power_protocol = @import("power-protocol");
const process = @import("process");
const service = @import("service");
const time = @import("time");
/// The contract this fixture's provider role claims — under `/protocol/test`,
/// the subtree every `/test/` binary is granted.
const contract = "test/registry";
/// Where the deputy and the laundered grandchild report their bind verdicts. The
/// driver binds it and listens; both children reach it by name like any client.
const verdict_contract = "test/verdict";
/// The name the deputy claims (it may — its supervisor is the kernel-started
/// driver) and the one its own child attempts (it may not — its supervisor is
/// the deputy, a task neither init nor the kernel started).
const deputy_contract = "test/deputy";
const laundered_contract = "test/laundered";
/// A contract this fixture is deliberately NOT granted. It belongs to the
/// display service, and no manifest row names this binary against it.
const forbidden = "display";
fn fail(step: []const u8) noreturn {
_ = logging.write("protocol-registry: FAIL ");
_ = logging.write(step);
_ = logging.write("\n");
process.exit(1);
}
// --- the provider role ------------------------------------------------------
/// Which instance this is, echoed to every caller. The point of the whole
/// exercise: a client cannot tell instances apart by the name it opened, so the
/// provider says so itself.
var mark: u8 = '?';
/// The provider's one operation, plus `handover_operation` — the capability-passing
/// verb the harness-exhaustion probe needs. Claiming the arriving capability with
/// `take` and *using* it is the point: it proves the harness handed over a working
/// handle, which a service whose table is full could not have been given.
fn answer(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = sender;
if (message.len != 0 and message[0] == handover_operation) {
if (arrived.take()) |handed| {
_ = ipc.send(handed, &[_]u8{mark}); // speak on it, then it is ours to close
_ = ipc.close(handed);
}
}
if (reply.len == 0) return 0;
reply[0] = mark;
return 1;
}
/// The one request byte the provider treats as "here is an endpoint, use it".
const handover_operation: u8 = 's';
// --- the laundering-deputy roles --------------------------------------------
/// One child's bind verdict, posted to the driver over `test/verdict`. Sent
/// asynchronously (no reply owed) so a child can report and exit without the
/// driver having to be waiting at that instant.
const Verdict = extern struct {
/// `deputy` or `launder` — which of the two spoke.
role: u8,
_reserved: [3]u8 = .{0} ** 3,
/// The registry's answer: 0 bound, or a negative errno.
status: i32,
};
const role_deputy: u8 = 'd';
const role_launder: u8 = 'l';
/// Report a verdict to the driver, best-effort: the driver has a deadline, and a
/// child that cannot reach it must not hang the boot.
fn report(role: u8, status: i32) void {
const verdict = Verdict{ .role = role, .status = status };
var attempts: u32 = 0;
while (attempts < 200) : (attempts += 1) {
if (channel.openEndpoint(verdict_contract)) |endpoint| {
defer _ = ipc.close(endpoint);
if (ipc.send(endpoint, std.mem.asBytes(&verdict))) return;
}
time.sleepMillis(20);
}
}
/// The middle of the laundered chain: a granted binary whose own supervisor is
/// the kernel-started driver. It binds — that is the control, and it is what
/// makes the refusal below mean something — then starts an instance of *itself*
/// and stays alive while that instance asks for a name, so the registry judges a
/// live, fully attestable chain rather than an orphan.
fn deputy() void {
const spare = ipc.createIpcEndpoint() orelse return;
report(role_deputy, verdictOf(deputy_contract, spare));
const exits = ipc.createIpcEndpoint() orelse return;
const child = process.spawnSupervised("protocol-registry-test", &.{"launder"}, exits) orelse return;
awaitExit(exits, child);
}
/// The laundered grandchild. Its binary is granted, and its supervisor's binary
/// is granted as a supervisor — every *name* in the row matches. The one thing
/// that does not is which task its supervisor is: nobody authorized this deputy,
/// so nothing it spawns inherits a name.
fn launder() void {
const spare = ipc.createIpcEndpoint() orelse return;
report(role_launder, verdictOf(laundered_contract, spare));
}
// --- the driver role --------------------------------------------------------
/// Ask whoever is on the other end of `endpoint` which instance they are. Null
/// if the call failed — which, after a provider dies, is `-EPEER`.
fn instanceOf(endpoint: ipc.Handle) ?u8 {
var reply: [8]u8 = undefined;
const n = ipc.call(endpoint, "?", &reply) catch return null;
if (n != 1) return null;
return reply[0];
}
/// Open `/protocol/<contract>` until the instance marked `wanted` answers,
/// re-resolving each time — exactly the recovery a client performs when its
/// provider dies. Returns the live endpoint. Handles from a failed attempt are
/// closed: a 32-slot table does not survive four hundred leaks.
fn reach(wanted: u8) ?ipc.Handle {
var attempts: u32 = 0;
while (attempts < 400) : (attempts += 1) {
if (channel.openEndpoint(contract)) |endpoint| {
if (instanceOf(endpoint)) |seen| {
if (seen == wanted) return endpoint;
}
_ = ipc.close(endpoint);
}
time.sleepMillis(20);
}
return null;
}
/// Block until child `id`'s exit notification lands on `endpoint`.
fn awaitExit(endpoint: ipc.Handle, id: u32) void {
var scratch: [8]u8 = undefined;
var guard: u32 = 0;
while (guard < 1000) : (guard += 1) {
const got = ipc.replyWait(endpoint, scratch[0..0], &scratch, null);
if (got.isChildExit() and got.childProcessId() == id) return;
}
fail("the provider's exit notification never arrived");
}
/// One bind, retried only while the registry is *unreachable* — a registry that
/// answered has decided. Null means it never answered, which the caller reports
/// in its own words: "not mounted yet" and "stopped serving mid-test" are the
/// same silence and very different findings.
fn verdictWithin(name: []const u8, endpoint: ipc.Handle) ?i32 {
var attempts: u32 = 0;
while (attempts < 200) : (attempts += 1) {
if (channel.bind(name, endpoint)) |status| return status;
time.sleepMillis(20);
}
return null;
}
/// The registry answers, whatever it answers — the first bind also waits out a
/// registry that has not mounted `/protocol` yet.
fn verdictOf(name: []const u8, endpoint: ipc.Handle) i32 {
return verdictWithin(name, endpoint) orelse fail("the registry never came up");
}
/// The registry's endpoint, obtained the way every process obtains it: resolve
/// `/protocol`. `fs_resolve` installs the backend's capability in ANY caller's
/// table, and the registrar's backend endpoint is PID 1's *supervision* endpoint
/// — the same mailbox its signals, timers, child deaths and power events land in.
/// That is precisely why what arrives there may not be believed on its content.
fn registryEndpoint() ?ipc.Handle {
var relative: [channel.path_maximum]u8 = undefined;
const route = file_system.fsResolve(channel.root, 0, &relative) orelse return null;
return switch (route) {
.kernel => null,
.backend => |backend| backend.handle,
};
}
/// Wait for both children of the laundering probe to report. Taken together
/// rather than one at a time because the two are independent processes and the
/// order they reach the driver is not the driver's business.
fn awaitVerdicts(endpoint: ipc.Handle) struct { deputy: i32, launder: i32 } {
var scratch: [64]u8 = undefined;
var from_deputy: ?i32 = null;
var from_launder: ?i32 = null;
var guard: u32 = 0;
while (guard < 2000) : (guard += 1) {
const got = ipc.replyWait(endpoint, scratch[0..0], &scratch, null);
if (got.isMessage() and got.len >= @sizeOf(Verdict)) {
const verdict = std.mem.bytesToValue(Verdict, scratch[0..@sizeOf(Verdict)]);
if (verdict.role == role_deputy) from_deputy = verdict.status;
if (verdict.role == role_launder) from_launder = verdict.status;
}
if (from_deputy) |d| {
if (from_launder) |l| return .{ .deputy = d, .launder = l };
}
}
fail("the deputy chain never reported its bind verdicts");
}
fn run() void {
// A spare endpoint to offer the registry. Every bind below is meant to be
// refused, so it is never actually claimed by anyone.
const spare = ipc.createIpcEndpoint() orelse fail("create an endpoint to offer");
// 1. A name this binary is not granted. Refusal is `-EPERM` at the bind, not
// silence: the caller *is* the provider, and telling it its manifest is
// wrong is not a leak.
const ungranted = verdictOf(forbidden, spare);
if (ungranted != -envelope.EPERM) fail("an ungranted bind was not refused with -EPERM");
_ = logging.write("protocol-registry: ungranted bind refused\n");
// 2. The kernel's residual rule: `/protocol` is claimed once, by PID 1, and
// the prefix is closed for the boot. Shadowing one contract by mounting
// under it is refused too, and so is deleting the namespace outright.
if (file_system.mount("/protocol", spare)) fail("a second mount at /protocol was allowed");
if (file_system.mount("/protocol/display", spare)) fail("a mount under /protocol was allowed");
if (file_system.fsUnmount("/protocol")) fail("unmounting /protocol was allowed");
_ = logging.write("protocol-registry: /protocol reserved\n");
// 3. A forged power event. Resolving `/protocol` hands this process — any
// process — a sendable handle to PID 1's mailbox, and the bytes below are
// byte-for-byte what the real power service publishes when the button is
// pressed. If the registrar believed payloads, this single `send` would
// run the whole stop sequence and park PID 1 in its final sleep: every
// service terminated, the registry gone for the rest of the boot, and no
// way back. So the assertion is that the machine is still here afterwards
// — the strongest one available, because a regression does not fail this
// line, it takes the entire boot down with it.
const registry = registryEndpoint() orelse fail("resolve /protocol");
var forged: [envelope.post_maximum]u8 = undefined;
const packet = power_protocol.Protocol.encodeEvent(.power_button, 0, .{}, &forged) orelse
fail("frame a forged power event");
if (!ipc.send(registry, packet)) fail("post a forged power event");
const still_serving = verdictWithin(forbidden, spare) orelse
fail("the registrar went silent after a forged power event — it acted on it");
if (still_serving != -envelope.EPERM) fail("the registry misanswered after a forged power event");
_ = logging.write("protocol-registry: forged power event ignored\n");
// 4. Sixty-four zero-length calls, each carrying a capability. The kernel
// installs a sent capability in the receiver's table whatever the message
// length, so every one of these hands PID 1 a handle — and PID 1 has
// thirty-two slots. A registrar whose ping path returns without closing
// runs out before this loop is half done, after which nothing can ever
// bind again and the machine cannot recover for the rest of the boot.
var storm: u32 = 0;
while (storm < 64) : (storm += 1) {
var scratch: [8]u8 = undefined;
_ = ipc.callCap(registry, "", &scratch, spare) catch {};
}
// The proof is a legitimate bind landing afterwards. This one doubles as
// the driver's listening post for step 7.
const verdicts = ipc.createIpcEndpoint() orelse fail("create the verdict endpoint");
const after_storm = verdictWithin(verdict_contract, verdicts) orelse
fail("the registrar went silent after capability-carrying pings");
if (after_storm != 0) fail("a legitimate bind failed after capability-carrying pings");
_ = logging.write("protocol-registry: capability-carrying pings did not exhaust the registrar\n");
// 5. A live owner's name is not for the taking. Collision is an error, never
// last-writer-wins — otherwise any process could impersonate any service.
const exits = ipc.createIpcEndpoint() orelse fail("create the exit endpoint");
const first = process.spawnSupervised("protocol-registry-test", &.{ "provider", "1" }, exits) orelse
fail("spawn the first provider");
const one = reach('1') orelse fail("the first provider never bound its contract");
const collision = verdictOf(contract, spare);
if (collision != -envelope.EBUSY) fail("a collision with a live owner was not refused with -EBUSY");
_ = logging.write("protocol-registry: collision refused\n");
// 6. The restart story. Kill the provider: the cached channel dies with it
// (the kernel fails calls on a dead endpoint rather than blocking
// forever), and re-resolving the same name reaches whatever instance the
// registry now points at. No reconnect verb, no client-side repair.
if (!process.kill(first)) fail("kill the first provider");
awaitExit(exits, first);
if (instanceOf(one) != null) fail("a call on a dead provider's channel succeeded");
_ = logging.write("protocol-registry: dead channel refused\n");
_ = process.spawnSupervised("protocol-registry-test", &.{ "provider", "2" }, exits) orelse
fail("spawn the replacement provider");
const two = reach('2') orelse fail("re-resolving never reached the restarted provider");
_ = logging.write("protocol-registry: restarted provider reached\n");
// 7. The laundering deputy. Two binds by the *same binary*, whose grant rows
// read identically — same claimant path, same supervisor path — differing
// only in which task the supervisor is:
//
// kernel -> this driver -> deputy the deputy binds (control)
// kernel -> this driver -> deputy -> ... the grandchild does not
//
// Nobody authorized the deputy to be a supervisor, so nothing it spawns
// inherits a name — which is the whole reason a supervisor is attested by
// task id and not by the string the kernel stamped on it. The control
// matters as much as the refusal: without it this step would pass just as
// happily against a registrar that refused everything.
const deputy_exits = ipc.createIpcEndpoint() orelse fail("create the deputy's exit endpoint");
_ = process.spawnSupervised("protocol-registry-test", &.{"deputy"}, deputy_exits) orelse
fail("spawn the deputy");
const chain = awaitVerdicts(verdicts);
if (chain.deputy != 0) fail("a granted binary spawned by an AUTHORIZED task was refused");
if (chain.launder != -envelope.EPERM) fail("a granted binary spawned by an UNAUTHORIZED task was not refused");
_ = logging.write("protocol-registry: laundering deputy refused\n");
// 8. A forged terminate SIGNAL. Step 3 proved a *payload* cannot reach PID 1's
// shutdown path; this is the same destination by the other road, and the
// interesting one, because nothing here is forged at all. `signal_bind`
// nominates where a process's own signals are delivered, and `process_signal`
// lets any task signal itself — both correct in isolation, and together a
// shutdown primitive the moment the delivery point may be somebody else's
// endpoint. `fs_resolve` hands every process a handle to PID 1's, so all it
// would take is: bind, then signal yourself. The badge the kernel stamps is
// genuine, which is exactly why init cannot filter it and the kernel has to.
//
// The control comes first and matters as much as the refusal: an endpoint we
// created is accepted, so what the second line refuses is *foreignness* and
// not signals-in-general. If the second line ever succeeds again it also
// steals our own binding, so the terminate below lands in PID 1 and takes the
// boot with it — the assertion is the machine still being here.
const signals = ipc.createIpcEndpoint() orelse fail("create the signal endpoint");
if (!process.bindSignals(signals)) fail("binding signals to an endpoint we created was refused");
if (process.bindSignals(registry)) fail("binding signals to the registry's endpoint was allowed");
if (!process.sendSignal(process.taskId(), .terminate)) fail("post ourselves a terminate signal");
const after_signal = verdictWithin(forbidden, spare) orelse
fail("the registrar went silent after a redirected terminate signal — it acted on it");
if (after_signal != -envelope.EPERM) fail("the registry misanswered after a redirected terminate signal");
_ = logging.write("protocol-registry: foreign signal binding refused\n");
// 9. The same rule for the other two kernel notifications. A timer landing
// carries no identity — no sender, no cookie — so a service cannot tell a
// timer it armed from one a stranger armed on its endpoint; init re-arms its
// heartbeat on every landing, so N smuggled timers leave N+1 beats running
// forever. Exit subscriptions are the same shape with a smaller blast radius
// (an eight-slot table, and a firehose of deaths aimed at a stranger).
// Control first again, and for the timer the control's *landing* is the proof
// the refusal is about ownership rather than a timer syscall that just fails.
const ticker = ipc.createIpcEndpoint() orelse fail("create the timer endpoint");
if (!time.timerOnce(ticker, 1)) fail("arming a timer on an endpoint we created was refused");
if (time.timerOnce(registry, 1)) fail("arming a timer on the registry's endpoint was allowed");
var tick: [8]u8 = undefined;
if (!ipc.replyWait(ticker, tick[0..0], &tick, null).isTimer()) fail("our own timer did not land");
if (process.subscribeExits(registry)) fail("subscribing the registry's endpoint to exit events was allowed");
if (!process.subscribeExits(ticker)) fail("subscribing an endpoint we created to exit events was refused");
_ = logging.write("protocol-registry: foreign timer and exit binding refused\n");
// 9b. Receiving is the same privilege, and it was the one member of the family
// left unguarded. Every process holds a sendable handle to the registrar's
// mailbox — `fs_resolve` installs one for anyone who asks — and a stranger
// that could *dequeue* there would not merely evade the grants this fixture
// checks: it would take the provider endpoints that ride `bind` requests
// straight out of the queue, and answer other clients' opens in the
// registrar's name. Sending to it stays legal; receiving on it must not be.
// A refusal comes back as a negative errno in the length register, which
// is the whole point: the call returns instead of parking us on someone
// else's queue, where a success would have blocked until a request it was
// never ours to see arrived.
var stolen: [8]u8 = undefined;
const theft = ipc.replyWait(registry, stolen[0..0], &stolen, null);
if (theft.len <= ~@as(usize, 0) - 4095) fail("receiving on the registry's endpoint was allowed");
_ = logging.write("protocol-registry: foreign receive refused\n");
// 10. The handle-table storm again, aimed at a **harness-run service** this
// time. Step 4 covers PID 1, which runs its own hand-written loop; every
// other service in the system — the VFS, the display compositor, the device
// manager, every driver — runs `library/kernel/service.zig`, and that loop
// had the identical hole: the zero-length ping answered without closing what
// the ping carried, and callbacks handed a bare handle they had no use for.
// The provider role below runs that same harness, so it stands in for all of
// them. Sixty-four capability-carrying pings is twice its thirty-two slots.
var storm2: u32 = 0;
while (storm2 < 64) : (storm2 += 1) {
var scratch: [8]u8 = undefined;
_ = ipc.callCap(two, "", &scratch, spare) catch {};
}
// The proof is a *capability-passing* operation still working: the kernel
// installs the sent handle in the receiver's table before the service sees
// the request, so against an exhausted service the call fails outright. And
// the provider answers on the handed-over endpoint, so the handle it claimed
// is a working one and not merely a number.
const handover = ipc.createIpcEndpoint() orelse fail("create the handover endpoint");
var handover_reply: [8]u8 = undefined;
const served = ipc.callCap(two, &[_]u8{handover_operation}, &handover_reply, handover) catch
fail("a capability-passing call failed after capability-carrying pings — the harness leaked them");
if (served.len != 1 or handover_reply[0] != '2') fail("the harness-run provider misanswered after capability-carrying pings");
var echoed: [8]u8 = undefined;
const back = ipc.replyWait(handover, echoed[0..0], &echoed, null);
if (!back.isMessage() or back.len != 1 or echoed[0] != '2') fail("the provider never spoke on the endpoint it was handed");
_ = logging.write("protocol-registry: capability-carrying pings did not exhaust the harness\n");
_ = logging.write("protocol-registry: 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, "provider")) {
const label = startup.arguments.get(2) orelse "?";
if (label.len != 0) mark = label[0];
service.run(64, .{ .service = contract, .on_message = answer });
return; // terminate arrived, or the bind was refused; either way we are done
}
if (std.mem.eql(u8, role, "deputy")) return deputy();
if (std.mem.eql(u8, role, "launder")) return launder();
if (std.mem.eql(u8, role, "run")) run();
}
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "shared-memory-client",
.root_source_file = b.path("shared-memory-client.zig"),
.imports = &.{ "ipc", "logging", "memory", "time" },
.imports = &.{ "channel", "ipc", "logging", "memory", "time" },
});
b.installArtifact(exe);
}
@@ -5,6 +5,7 @@
//! capability-passing path.
const channel = @import("channel");
const ipc = @import("ipc");
const time = @import("time");
const memory = @import("memory");
@@ -19,7 +20,7 @@ fn expected(i: usize) u8 {
fn lookupServer() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.shared_memory_test)) |h| return h;
if (channel.openEndpoint("test/shared-memory")) |h| return h;
time.sleepMillis(50);
}
return null;
@@ -1,5 +1,5 @@
//! test/system/services/shared-memory-server — the receiving half of the shared-memory test (docs/display-v2.md V2).
//! It registers under `ServiceId.shared_memory_test`; when `shared-memory-client` calls it carrying a
//! It binds `/protocol/test/shared-memory`; when `shared-memory-client` calls it carrying a
//! shared-memory capability, it `shared_memory_map`s that capability and checks the client's pattern
//! is visible through the mapping — proving the two processes share the same physical pages
//! (not a copy). On success it prints `shared-memory: shared 4096 bytes ok`, the test's marker.
@@ -16,11 +16,12 @@ fn expected(i: usize) u8 {
return @truncate(i *% 7 +% 3);
}
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = message;
_ = reply;
_ = sender;
const cap = capability orelse {
// Peeked, not claimed: the mapping survives the handle, so the turn closes it.
const cap = arrived.peek() orelse {
_ = logging.write("shared-memory: shared FAILED (no capability)\n");
return 0;
};
@@ -40,5 +41,5 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
}
pub fn main() void {
service.run(64, .{ .service = .shared_memory_test, .on_message = onMessage });
service.run(64, .{ .service = "test/shared-memory", .on_message = onMessage });
}