library: the harness keeps the subscribers, and an id belongs to whoever opened it

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

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

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

Suite 111/111, with a new case in which one process holds a file and a
layer, hands both ids to a second process, and finds them untouched after
that process has tried everything with them.
This commit is contained in:
Daniel Samson 2026-08-01 09:05:26 +01:00
parent 2719b93530
commit 1b1c587c14
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
29 changed files with 1072 additions and 335 deletions

View File

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

View File

@ -63,6 +63,7 @@
.@"virtio-gpu" = .{ .path = "system/drivers/virtio-gpu" },
.@"vfs-test" = .{ .path = "test/system/services/vfs-test", .lazy = true },
.@"fat-test" = .{ .path = "test/system/services/fat-test", .lazy = true },
.@"badge-scope-test" = .{ .path = "test/system/services/badge-scope-test", .lazy = true },
.@"shared-memory-server" = .{ .path = "test/system/services/shared-memory-server", .lazy = true },
.@"shared-memory-client" = .{ .path = "test/system/services/shared-memory-client", .lazy = true },
.@"crash-test" = .{ .path = "test/system/services/crash-test", .lazy = true },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -188,9 +188,15 @@ zombie state or privileged snooping:
state by all along is the id the exit event carries.
Subscription, not broadcast-to-everyone: only processes that asked receive
events, the kernel keeps a bounded subscriber table, and delivery is the same
non-blocking coalescing notification as everything else — a dying process never
waits on its mourners. Subscribing is ungated, like `process_enumerate`: what is
events, the kernel keeps a bounded subscriber table (sixteen — a normal boot
already fields six, since this is what *every* provider with per-client state
releases on), and delivery is the same non-blocking coalescing notification as
everything else — a dying process never waits on its mourners.
A service does not usually write the sweep itself: the shared service harness
subscribes for it and drops a dead task's event subscriptions
(`service.Subscribers`), and a provider adds its own handler only for state the
harness knows nothing about — open files, layers, device tokens.
Subscribing is ungated, like `process_enumerate`: what is
running (and dying) is not a secret between cooperating processes. Subscribers
do not receive the exit reason — the filesystem server does not care *why*
the client died.
@ -285,6 +291,10 @@ callbacks (`on_terminate`, `on_reload`) for programs that want defaults.
`service` owns the `replyWait` loop and folds every event source — signals,
child exits, protocol messages — into callbacks, with the vocabulary's defaults:
it also owns the **subscriber side** of any protocol that declares events
(`service.Subscribers`: the table, the reserved `subscribe`/`unsubscribe` verbs,
the fan-out, and the sweep on a subscriber's published exit), so every event
stream in the system behaves identically.
`terminate` returns from the loop (clean exit), the common `ping` is answered automatically,
`reload` is ignored unless overridden. One loop, no locking, nothing reentrant. A
service author writes domain logic; the lifecycle contract is satisfied by the

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,8 +11,8 @@ pub fn build(b: *std.Build) void {
.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",
"ipc", "logging", "memory", "process", "scanout-protocol",
"service", "thread", "time",
},
.threaded = true, // real atomics/TLS (docs/threading.md)
});

View File

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

View File

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

View File

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

View File

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

View File

@ -658,6 +658,18 @@ CASES = [
"timeout": 150,
"expect": r"fat-test: rename ok",
"fail": r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"},
# P4c: badge-scoped per-client ids. Two processes of one fixture, on the same
# boot: the owner holds a FAT node id and a compositor layer id, the intruder
# names both and must be refused — while its own node and layer, and the
# owner's after the attempt, keep working. Every refusal is paired with a
# control, so a provider that refused everything (or honoured everything)
# fails this case rather than passing it. Reuses the fat-mount build.
{"name": "badge-scope",
"build_case": "fat-mount",
"smp": 4,
"timeout": 150,
"expect": r"(?s)(?=.*badge-scope-test: ok)(?=.*badge-scope-test: owner intact)",
"fail": r"badge-scope-test: FAILED|DANOS-TEST-RESULT: FAIL"},
# Phase 2d: filesystem timestamps — a freshly-created file's mtime is a real
# current wall-clock time (stamped from the RTC), read back through stat.
{"name": "fat-mtime",

View File

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

View File

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

View File

@ -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 = .{""},
}