diff --git a/build.zig b/build.zig index 978cee7..412651e 100644 --- a/build.zig +++ b/build.zig @@ -326,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 diff --git a/build.zig.zon b/build.zig.zon index 76961d0..bc568ab 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 }, diff --git a/docs/device-driver-development/device-manager.md b/docs/device-driver-development/device-manager.md index 118c80d..7607cd9 100644 --- a/docs/device-driver-development/device-manager.md +++ b/docs/device-driver-development/device-manager.md @@ -81,6 +81,16 @@ one world. | 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, diff --git a/docs/device-driver-development/display.md b/docs/device-driver-development/display.md index 7eadedd..a8ec727 100644 --- a/docs/device-driver-development/display.md +++ b/docs/device-driver-development/display.md @@ -211,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 diff --git a/docs/device-driver-development/input.md b/docs/device-driver-development/input.md index 51d824f..62ba9e6 100644 --- a/docs/device-driver-development/input.md +++ b/docs/device-driver-development/input.md @@ -108,15 +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 runs on the - shared harness ([service.zig](../../library/kernel/service.zig)) like every other, so it - answers the universal ping and exits on `terminate`; it was the last hand-rolled receive - loop in the tree, and the last service a shutdown had to kill rather than ask. +- 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 diff --git a/docs/file-system-development/vfs-protocol.md b/docs/file-system-development/vfs-protocol.md index 41528d6..d34e45d 100644 --- a/docs/file-system-development/vfs-protocol.md +++ b/docs/file-system-development/vfs-protocol.md @@ -212,10 +212,24 @@ 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 diff --git a/docs/os-development/power.md b/docs/os-development/power.md index 1453a12..9f3889d 100644 --- a/docs/os-development/power.md +++ b/docs/os-development/power.md @@ -46,8 +46,12 @@ 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 -packet, 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 +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: @@ -69,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 diff --git a/docs/os-development/process-lifecycle.md b/docs/os-development/process-lifecycle.md index 37299e9..99656dc 100644 --- a/docs/os-development/process-lifecycle.md +++ b/docs/os-development/process-lifecycle.md @@ -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 diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 8d34a4c..70e53ec 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.** | | | |---|---| -| Working on | **P4c** — harness subscriber lift + badge-scoped client ids | -| Branch carrying it | `feat/security-group-3` (pushed to origin) | -| On `main` | Phase 0, PM, H1, P1, P2, P3 — groups 1 and 2 merged | -| Awaiting merge | P4a, P4b — land with the group 3 merge | -| Suite | 110 cases, all passing | +| 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 @@ -67,8 +67,19 @@ group boundary. - [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) -- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers -- [ ] **merge** group 3 → main, push +- [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 @@ -456,6 +467,33 @@ the wire formats will want them:* 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 diff --git a/library/kernel/build.zig b/library/kernel/build.zig index 0b845b2..4361821 100644 --- a/library/kernel/build.zig +++ b/library/kernel/build.zig @@ -87,11 +87,14 @@ pub fn build(b: *std.Build) void { }); // 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). + // 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 = "channel", .module = channel }, + .{ .name = "envelope", .module = protocol.module("envelope") }, .{ .name = "ipc", .module = ipc }, .{ .name = "process", .module = process }, }, diff --git a/library/kernel/service.zig b/library/kernel/service.zig index cfde3cd..396f6d6 100644 --- a/library/kernel/service.zig +++ b/library/kernel/service.zig @@ -12,6 +12,12 @@ //! 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 @@ -19,9 +25,23 @@ //! is the diagnosis (see docs/ipc.md). 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. @@ -60,8 +80,216 @@ pub const Callbacks = struct { /// `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, }; +/// 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 @@ -74,6 +302,9 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void { 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; } @@ -105,6 +336,13 @@ 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; } diff --git a/library/protocol/envelope/envelope.zig b/library/protocol/envelope/envelope.zig index b82b14a..7c00b8d 100644 --- a/library/protocol/envelope/envelope.zig +++ b/library/protocol/envelope/envelope.zig @@ -72,6 +72,41 @@ pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoin 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 @@ -876,6 +911,21 @@ test "a truncated packet answers -EPROTO" { // 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 })); diff --git a/library/protocol/input/input-protocol.zig b/library/protocol/input/input-protocol.zig index f14afe5..c15757f 100644 --- a/library/protocol/input/input-protocol.zig +++ b/library/protocol/input/input-protocol.zig @@ -13,7 +13,8 @@ //! - **subscribe** is the *reserved* verb, not one of this protocol's own: its shape — a //! synchronous call whose attached capability is the subscriber's endpoint — is exactly //! what `envelope.operation_subscribe` means everywhere. The interest mask travels as the -//! packet's tail (`Subscribe`), because a reserved verb carries no typed request. +//! 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 @@ -294,12 +295,6 @@ pub const InputEvent = extern struct { // --- the contract ----------------------------------------------------------- -/// The body of a `subscribe` — the envelope's reserved verb 2, whose shape (a call whose -/// capability is the subscriber's own endpoint) this protocol adopts wholesale. A reserved -/// verb has no typed request, so the mask travels as the packet's tail and `encodeSubscribe` -/// is how a client lays it down. Zero means every class. -pub const Subscribe = extern struct { device_mask: u32 = 0 }; - pub const Protocol = envelope.Define(.{ .name = "input", .version = 1, @@ -334,23 +329,12 @@ pub fn eventOfDevice(device: u32) ?Event { } /// Frame a `subscribe` request: the reserved verb's header, then the interest mask. Null if -/// the buffer is too small. Spelled here rather than at each caller so the one place that -/// knows a reserved verb carries its body in the tail is the protocol module. +/// 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 { - const total = envelope.prefix_size + @sizeOf(Subscribe); - if (buffer.len < total) return null; - const header = envelope.Header{ .operation = envelope.operation_subscribe }; - const body = Subscribe{ .device_mask = device_mask }; - @memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header)); - @memcpy(buffer[envelope.prefix_size..][0..@sizeOf(Subscribe)], std.mem.asBytes(&body)); - return buffer[0..total]; -} - -/// The interest mask out of a `subscribe` packet's tail, on the provider's side. A caller -/// that sent no mask at all means every class, which is what a zero mask means anyway. -pub fn decodeSubscribe(tail: []const u8) Subscribe { - if (tail.len < @sizeOf(Subscribe)) return .{}; - return std.mem.bytesToValue(Subscribe, tail[0..@sizeOf(Subscribe)]); + return envelope.encodeSubscribe(device_mask, buffer); } test "an event of every class fits the push floor, header included" { @@ -388,7 +372,9 @@ test "a subscribe carries its mask in the tail of the reserved verb" { var buffer: [envelope.packet_maximum]u8 = undefined; const packet = encodeSubscribe(device_mouse, &buffer).?; try std.testing.expectEqual(envelope.operation_subscribe, envelope.headerOf(packet).?.operation); - try std.testing.expectEqual(device_mouse, decodeSubscribe(packet[envelope.prefix_size..]).device_mask); + // 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), decodeSubscribe(&.{}).device_mask); + try std.testing.expectEqual(@as(u32, 0), envelope.decodeSubscribe(&.{}).interest); } diff --git a/system/configuration/protocol.csv b/system/configuration/protocol.csv index cb0601d..9ba60e7 100644 --- a/system/configuration/protocol.csv +++ b/system/configuration/protocol.csv @@ -148,6 +148,16 @@ /test/system/services/input-source, kernel, open, input /test/system/services/input-test, kernel, open, input +# The 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 diff --git a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig index e581cef..16bfb7a 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig @@ -61,22 +61,33 @@ 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; -/// Remember (or replace) the endpoint that reports for `device_token`. Returns -/// whether the table kept the handle — false means the caller still owns it and -/// must dispose of it. A re-open supersedes the previous endpoint, and the one -/// it displaced is closed here: the table holds exactly one reference per slot. -fn recordOpen(device_token: u64, report_endpoint: usize) bool { +/// 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 true; @@ -84,13 +95,33 @@ fn recordOpen(device_token: u64, report_endpoint: usize) bool { } for (&opens) |*open| { if (!open.used) { - open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint }; + 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 { for (&opens) |*open| { if (open.used and open.device_token == device_token) return open.report_endpoint; @@ -98,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 @@ -105,6 +153,10 @@ 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 @@ -543,11 +595,16 @@ const handlers = Serve.Handlers{ 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; // 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, endpoint)) capability_claimed = true; + if (recordOpen(invocation.target, invocation.sender, endpoint)) capability_claimed = true; } var opened = usb_transfer_protocol.Opened{ @@ -576,6 +633,7 @@ fn onOpen(_: void, invocation: Invocation(void), answer: Answer(usb_transfer_pro /// 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); @@ -600,6 +658,7 @@ fn onControl(_: void, invocation: Invocation(usb_transfer_protocol.Control), ans /// 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; @@ -610,6 +669,7 @@ fn onInterruptSubscribe(_: void, invocation: Invocation(usb_transfer_protocol.In /// address), so sector-sized data never crosses IPC. 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; @@ -633,6 +693,12 @@ fn onDmaAttach(_: void, invocation: Invocation(void), _: Answer(void)) isize { /// 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()); diff --git a/system/drivers/usb-xhci-bus/usb-xhci-library.zig b/system/drivers/usb-xhci-bus/usb-xhci-library.zig index 89e5f64..fb0862d 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-library.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-library.zig @@ -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 { diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 4104808..615a8ae 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -1520,7 +1520,15 @@ 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; @@ -1538,16 +1546,27 @@ fn systemProcessSubscribe(state: *architecture.CpuState) void { 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 diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 4758511..a98c662 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -2248,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 @@ -2739,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(); } diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index 169bf42..b29a7c2 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -60,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 }; @@ -201,6 +200,7 @@ pub fn main(init: process.Init) void { .init = onInit, .on_message = onMessage, .on_notification = onNotification, + .subscribers = Subscriptions.hooks, }); } @@ -407,13 +407,13 @@ fn publishNotify(node: *aml.Node, code: u64) void { const notice = power_protocol.Notice{ .code = @truncate(code), .hid = hid }; std.log.info("power: notify {s} code {d}", .{ hid[0..7], code }); if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) { - publish(.battery, notice); + Subscriptions.publish(.battery, 0, notice); } else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) { - publish(.ac, notice); + Subscriptions.publish(.ac, 0, notice); } else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) { - publish(.lid, notice); + Subscriptions.publish(.lid, 0, notice); } else { - publish(.notify, notice); + Subscriptions.publish(.notify, 0, notice); } } @@ -425,28 +425,10 @@ fn writeHex2(out: []u8, n: u32) void { } fn publishButton() void { - publish(.power_button, .{}); -} - -/// Push one event to every subscriber. The kind is the packet's operation, so -/// this is framed once, outside the loop — every subscriber gets identical -/// bytes. A subscriber whose endpoint stops accepting (it died) is dropped on -/// the failed send, so a dead one can never stall the rest. -fn publish(comptime kind: power_protocol.Event, notice: power_protocol.Notice) void { - var packet: [envelope.post_maximum]u8 = undefined; - const framed = power_protocol.Protocol.encodeEvent(kind, 0, notice, &packet) orelse return; - for (&subscribers) |*slot| { - if (slot.*) |handle| { - if (!ipc.send(handle, framed)) 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). @@ -468,57 +450,37 @@ 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 generated power dispatch. One provider per system, so the handler context -/// is empty and the subscriber table stays in this file's globals. -const Serve = power_protocol.Protocol.Provider(void); - const Invocation = envelope.Invocation; const Answer = envelope.Answer; -/// Set by `onSubscribe` when the subscriber table has taken the capability the -/// call carried, and read by `onMessage`, where the turn's `Arrival` lives. -var capability_claimed = false; - /// The power contract: the reserved `subscribe` (the subscriber's endpoint as -/// the call's capability) and `shutdown` (subscribers only). Device discovery -/// uses a different endpoint — the device manager's — so nothing here handles a -/// tree report. +/// 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 { - capability_claimed = false; - const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply); - if (capability_claimed) _ = arrived.take(); - return written; + return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply); } -const handlers = Serve.Handlers{ .shutdown = onShutdown, .subscribe = onSubscribe }; - -/// The subscriber's endpoint is claimed only when a slot takes it; a full table -/// refuses and the turn closes what arrived. -fn onSubscribe(_: void, invocation: Invocation(void), _: Answer(void)) isize { - const endpoint = invocation.capability orelse return -envelope.EPROTO; - for (&subscribers, 0..) |*slot, index| { - if (slot.* == null) { - slot.* = endpoint; - subscriber_tasks[index] = invocation.sender; - capability_claimed = true; - return 0; - } - } - return -envelope.ENOSPC; -} +/// `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. +/// 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 (!isSubscriber(invocation.sender)) return -envelope.EPERM; + if (!Subscriptions.has(invocation.sender)) return -envelope.EPERM; enterS5(); return 0; } diff --git a/system/services/device-manager/device-manager.zig b/system/services/device-manager/device-manager.zig index 4954834..6b87d14 100644 --- a/system/services/device-manager/device-manager.zig +++ b/system/services/device-manager/device-manager.zig @@ -27,9 +27,11 @@ const device_manager_protocol = @import("device-manager-protocol"); const envelope = @import("envelope"); const registry = @import("device-registry"); -/// The generated device-manager dispatch. One manager per system, so the handler -/// context is empty and the tables stay in this file's globals. -const Serve = device_manager_protocol.Protocol.Provider(void); +/// 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; @@ -156,31 +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; - -/// Push one event to every subscriber: the same struct a bus driver *called* -/// with, framed as an event instead — one encoding, both directions, told apart -/// by the packet's verb rather than by anything inside it. A subscriber whose -/// endpoint stops accepting (it died) is dropped on the failed send. -fn publish( - comptime event: device_manager_protocol.Event, - target: u64, - payload: device_manager_protocol.Protocol.PayloadOf(event), -) void { - var packet: [envelope.post_maximum]u8 = undefined; - const framed = device_manager_protocol.Protocol.encodeEvent(event, target, payload, &packet) orelse return; - for (&subscribers) |*slot| { - if (slot.*) |handle| { - if (!ipc.send(handle, framed)) 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. @@ -225,7 +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; - publish(.child_removed, 0, .{ .parent = child.parent, .bus_address = child.bus_address }); + Serve.publish(.child_removed, 0, .{ .parent = child.parent, .bus_address = child.bus_address }); } } } @@ -240,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; } @@ -308,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()}); @@ -410,27 +400,17 @@ fn initialise(endpoint: ipc.Handle) bool { return true; } -/// Set by `onSubscribe` when the subscriber table has taken ownership of the -/// capability 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` — deliberately, since a handler has no business closing the -/// turn's property — so the *claim* travels back out this way. One turn, one -/// handler, one thread: there is nothing here to race. -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; + return Serve.dispatch({}, handlers, message, sender, arrived, reply); } +/// `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, - .subscribe = onSubscribe, }; /// The handshake. The device this driver was assigned is the packet's target. @@ -470,7 +450,7 @@ fn onChildAdded(_: void, invocation: Invocation(device_manager_protocol.ChildAdd if (driverByProcess(sender)) |driver| { 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) publish(.child_added, device_id, report); + 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 @@ -556,24 +536,11 @@ fn onEnumerate(_: void, _: Invocation(void), answer: Answer(void)) isize { return @intCast(written); } -/// The reserved `subscribe` verb: an application's endpoint arrived as the call's -/// capability. The table taking a slot is what claims it; a full table refuses -/// and lets the turn close it, so a subscribe storm cannot spend the handle table. -fn onSubscribe(_: void, invocation: Invocation(void), _: Answer(void)) isize { - const endpoint = invocation.capability orelse return -envelope.EPROTO; - for (&subscribers) |*slot| { - if (slot.* == null) { - slot.* = endpoint; - capability_claimed = true; // the table holds it from here - return 0; - } - } - return -envelope.ENOSPC; -} - 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; } @@ -592,5 +559,6 @@ pub fn main(init: process.Init) void { .init = initialise, .on_message = onMessage, .on_notification = onNotification, + .subscribers = Serve.hooks, }); } diff --git a/system/services/display/build.zig b/system/services/display/build.zig index 93ac232..a3525fa 100644 --- a/system/services/display/build.zig +++ b/system/services/display/build.zig @@ -10,9 +10,9 @@ pub fn build(b: *std.Build) void { .name = "display", .root_source_file = b.path("display.zig"), .imports = &.{ - "channel", "display-client", "display-protocol", "driver", "envelope", "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) }); diff --git a/system/services/display/display.zig b/system/services/display/display.zig index 780d136..e0ddeb7 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -19,6 +19,7 @@ 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"); @@ -74,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, @@ -189,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; @@ -197,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, @@ -420,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(); @@ -586,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; }; @@ -603,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; @@ -635,9 +655,35 @@ fn initialise(endpoint: ipc.Handle) bool { // id a u32: a value that does not fit is not a layer of ours, and `layerAt` // refuses it the same way an out-of-range one is refused. -fn targetLayer(target: u64) ?u32 { - if (target > std.math.maxInt(u32)) return null; - return @intCast(target); +/// 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; +} + +/// 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 + } } fn onInfo(_: void, _: Invocation(void), answer: Answer(display_protocol.Info)) isize { @@ -648,37 +694,37 @@ fn onInfo(_: void, _: Invocation(void), answer: Answer(display_protocol.Info)) i fn onCreateLayer(_: void, invocation: Invocation(display_protocol.CreateLayer), answer: Answer(display_protocol.Created)) isize { const request = invocation.request; - const slot = createLayer(request.x, request.y, request.width, request.height, request.z, request.visible != 0) orelse return refused; + 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) orelse return refused; + 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) orelse return refused; + 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) orelse return refused; + 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) orelse return refused; + 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) orelse return refused; + 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) }; @@ -733,13 +779,18 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arriva return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply); } -/// 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. +/// 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(); } diff --git a/system/services/fat/fat.zig b/system/services/fat/fat.zig index e266a27..e62633c 100644 --- a/system/services/fat/fat.zig +++ b/system/services/fat/fat.zig @@ -66,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; @@ -78,16 +79,31 @@ 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; } /// What a handler returns when the thing asked for is not there — a bad node id, -/// a path that does not resolve, a mutation the volume refused. One errno for all -/// of them, because a filesystem's failures are all "no such thing" as far as the -/// file API can act on them. +/// 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 @@ -230,14 +246,14 @@ fn onOpen(_: void, invocation: Invocation(vfs_protocol.Open), answer: Answer(vfs } fn onRead(_: void, invocation: Invocation(vfs_protocol.Read), answer: Answer(void)) isize { - const o = openAt(invocation.target) orelse return refused; + 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 = openAt(invocation.target) orelse return refused; + 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) }); @@ -245,7 +261,7 @@ fn onWrite(_: void, invocation: Invocation(vfs_protocol.Write), answer: Answer(v } fn onStatus(_: void, invocation: Invocation(void), answer: Answer(vfs_protocol.FileStatus)) isize { - const o = openAt(invocation.target) orelse return refused; + 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; @@ -255,7 +271,7 @@ fn onStatus(_: void, invocation: Invocation(void), answer: Answer(vfs_protocol.F /// cursor past the last child — is an entry with no name, which is how the /// protocol spells it now that the reply's length always counts the fixed part. fn onReaddir(_: void, invocation: Invocation(vfs_protocol.Readdir), answer: Answer(vfs_protocol.DirectoryEntry)) isize { - const o = openAt(invocation.target) orelse return refused; + const o = openFor(invocation.target, invocation.sender) orelse return refused; if (!o.node.is_directory) { answer.set(.{}); return 0; @@ -272,8 +288,13 @@ fn onReaddir(_: void, invocation: Invocation(vfs_protocol.Readdir), answer: Answ 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 { - if (openAt(invocation.target)) |o| o.used = false; + 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 diff --git a/system/services/input/build.zig b/system/services/input/build.zig index ce9499b..c5b57dc 100644 --- a/system/services/input/build.zig +++ b/system/services/input/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "input", .root_source_file = b.path("input.zig"), - .imports = &.{ "envelope", "input-protocol", "ipc", "logging", "process", "service" }, + .imports = &.{ "envelope", "input-protocol", "ipc", "logging", "service" }, }); b.installArtifact(exe); } diff --git a/system/services/input/input.zig b/system/services/input/input.zig index 70cadac..13b25bb 100644 --- a/system/services/input/input.zig +++ b/system/services/input/input.zig @@ -20,133 +20,52 @@ //! 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 +//! P4a moved this service onto the shared harness (library/kernel/service.zig) — it was the //! last hand-rolled receive loop in the tree, and the one service that answered neither the -//! universal ping nor a `terminate` signal — so a shutdown had to kill it. The subscriber -//! table, the fan-out, and the prune-on-subscribe below are unchanged; lifting *those* into -//! the harness is a later milestone, and doing it here would have hidden this one. +//! 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 envelope = @import("envelope"); const ipc = @import("ipc"); -const process = @import("process"); const service = @import("service"); const logging = @import("logging"); const input_protocol = @import("input-protocol"); +const envelope = @import("envelope"); -/// The generated input dispatch. One fan-out point per process, so the handler -/// context is empty and the subscriber table stays in this file's globals. -const Serve = input_protocol.Protocol.Provider(void); +/// 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); const Invocation = envelope.Invocation; const Answer = envelope.Answer; -/// One registered subscriber: the endpoint we push events to (a capability it handed us at -/// subscribe time) and the task id that owns it (the subscribe call's badge), so a slot -/// left behind by a subscriber that exited can be reclaimed. -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, -}; - -var subscribers = [_]Subscriber{.{}} ** 8; - -/// 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; - } - } - // The slot owns the endpoint capability it was handed, so reclaiming the - // slot closes it — otherwise a process that subscribes and dies costs a - // handle-table slot that never comes back. - if (!alive) { - _ = ipc.close(sub.endpoint); - 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. -/// -/// The class is the packet's operation, so the fan-out picks the event by device and the -/// packet is framed once, outside the loop — every subscriber of a class gets identical -/// bytes, which is what "one fan-out point per event domain" means on the wire. +/// 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 class = input_protocol.eventOfDevice(event.device) orelse return; // no class wants it - var packet: [envelope.post_maximum]u8 = undefined; - const framed = switch (class) { - .keyboard => input_protocol.Protocol.encodeEvent(.keyboard, 0, event.asKeyboard() orelse return, &packet), - .mouse => input_protocol.Protocol.encodeEvent(.mouse, 0, event.asMouse() orelse return, &packet), - .joystick => input_protocol.Protocol.encodeEvent(.joystick, 0, event.asJoystick() orelse return, &packet), - } orelse return; - - const bit = input_protocol.deviceBit(event.device); - for (&subscribers) |*sub| { - if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, framed); + 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), } } -/// Set by `onSubscribe` when the subscriber table has taken ownership of the capability the -/// call carried, and read by `onMessage`, which is where the turn's `Arrival` lives. The -/// generated dispatch hands a handler the raw handle rather than the `Arrival` — deliberately, -/// since a handler has no business closing the turn's property — so the *claim* has to travel -/// back out this way. One turn, one handler, one thread: there is nothing here to race. -var capability_claimed = false; - -/// The reserved `subscribe` verb: register the caller's endpoint (the call's capability) for -/// the classes in the packet's tail. Refusals simply return, and the turn closes what arrived -/// — the ownership rule the harness states (`ipc.Arrival`), unchanged by the move onto it. -fn onSubscribe(_: void, invocation: Invocation(void), _: Answer(void)) isize { - const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed - // A zero mask means "everything" (a subscriber that named no class still wants input). - const requested = input_protocol.decodeSubscribe(invocation.tail).device_mask; - const mask = if (requested == 0) input_protocol.device_all else requested; - pruneDeadSubscribers(); - if (!addSubscriber(endpoint, invocation.sender, mask)) return -envelope.ENOSPC; // table full - capability_claimed = true; // the subscriber table holds it until that task dies - return 0; -} - fn onPublish(_: void, invocation: Invocation(input_protocol.InputEvent), _: Answer(void)) isize { broadcast(invocation.request); return 0; } -const handlers = Serve.Handlers{ .publish = onPublish, .subscribe = onSubscribe }; +/// `subscribe` and `unsubscribe` are absent on purpose: the harness answers both. +const handlers = Subscriptions.Handlers{ .publish = onPublish }; fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize { - capability_claimed = false; - const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), out); - if (capability_claimed) _ = arrived.take(); - return written; + return Subscriptions.dispatch({}, handlers, message, sender, arrived, out); } fn initialise(_: ipc.Handle) bool { @@ -162,5 +81,6 @@ pub fn main() void { .service = "input", .init = initialise, .on_message = onMessage, + .subscribers = Subscriptions.hooks, }); } diff --git a/test/qemu_test.py b/test/qemu_test.py index 2b7cab3..5426a32 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -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", diff --git a/test/system/services/badge-scope-test/badge-scope-test.zig b/test/system/services/badge-scope-test/badge-scope-test.zig new file mode 100644 index 0000000..ab00422 --- /dev/null +++ b/test/system/services/badge-scope-test/badge-scope-test.zig @@ -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; +} diff --git a/test/system/services/badge-scope-test/build.zig b/test/system/services/badge-scope-test/build.zig new file mode 100644 index 0000000..acff449 --- /dev/null +++ b/test/system/services/badge-scope-test/build.zig @@ -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); +} diff --git a/test/system/services/badge-scope-test/build.zig.zon b/test/system/services/badge-scope-test/build.zig.zon new file mode 100644 index 0000000..9de2d02 --- /dev/null +++ b/test/system/services/badge-scope-test/build.zig.zon @@ -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 = .{""}, +}