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