From 7d8aa51234b07992a6b7024208d597731b8e1ca0 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:12:20 +0100 Subject: [PATCH] kernel: maximum_children_per_parent is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second invented ceiling. It was written to stop a driver looping device_register and exhausting a shared table — but there is no shared table to exhaust any more, and each registrar already has its own allowance, so a runaway costs only itself. It never bounded a determined caller in the first place: 16 children per parent, and nothing stopped it claiming more parents. What it reliably did was refuse a real PCI bus with more than 16 functions, which is how an AMD Ryzen booted with a working display, no USB and no storage. The constant, its check, and the now-unused childCount all go. TooManyChildren survives with one meaning instead of two: the caller is at its per-registrar allowance. This was unblocked from the moment D9 landed. The plan said so — "once the quota exists the per-parent cap is redundant whether or not D6 has landed" — in the same edit that left the step tagged "blocked on D6". Three iterations were then spent re-reading that tag instead of the sentence beside it. The containment test now asserts 64 children under one parent, four times the old ceiling; restoring the cap fails it. Suite 118/118. --- docs/bounds-track-plan.md | 65 +++++++++++++++++--------------- system/kernel/devices-broker.zig | 27 +------------ system/kernel/tests.zig | 37 +++++++++--------- 3 files changed, 54 insertions(+), 75 deletions(-) diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index 92a56f0..0af5806 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -48,16 +48,17 @@ that cannot safely run in user space.** | D10 | Every driver hellos, on its own merits (liveness, one class of driver) | not started — optional, independent | | D6 | `device_claim` refuses a device the caller was not handed; the hole is closed | not started | | D7 | Zero-resource devices stop being kernel objects — inventory moves to the manager | **blocked** — nothing else mints their ids; see question 8 | -| D8 | **`maximum_children_per_parent` deleted** — the authorisation it stood in for exists | **blocked on D6**, and now ordered after D9 | +| D8 | **`maximum_children_per_parent` deleted** | **done** — it was unblocked from the moment D9 landed; I kept reading my own stale label | | D9 | The device table becomes dynamic; **`maximum_devices` deleted**; per-holder quota declared | **done** — one of the two invented numbers is gone | -**Run 2 stops, blocked.** Landed: D0, D1, D2, D4, D9, and D5 for three of five -claimants (`usb-xhci-bus`, `pci-bus`, `virtio-gpu`). `maximum_devices` no longer exists, -delegation is atomic with the spawn, and the suite is 118/118. +**Run 2 stops, blocked on one question.** Landed: D0, D1, D2, D4, D8, D9, and D5 for +three of five claimants (`usb-xhci-bus`, `pci-bus`, `virtio-gpu`). Suite 118/118. -Blocked: **D6 and D8 on question 9** (`ps2-bus` needs two devices and ignores its -assignment; discovery needs a node nobody assigns), and **D7 on question 8**. So -`maximum_children_per_parent` — the second invented number — is one answer away. +**Both invented ceilings are gone.** `maximum_devices` and +`maximum_children_per_parent` no longer exist, and delegation is atomic with the spawn. + +What remains is not a number: **D6 closes the claiming hole** and is blocked on +question 9, and D7 moves the inventory and is blocked on question 8. Ordering is load-bearing. D1–D2 built and proved the mechanism with nothing depending on it. D4–D5 move each claimant across one at a time, so the suite stays green throughout @@ -90,34 +91,38 @@ They land together, with the manager claiming only for drivers in an explicit anything in D4 — recorded rather than dismissed, because D4 moved the `hello` earlier and so did shift boot timing. Watch it across the remaining steps. -### Open question 9 — two drivers need a device nobody assigned them +### Open question 9 — danos has two device-acquisition patterns; D6 fits only one -D0 made delivery atomic and D5 converted `virtio-gpu` with it. The last two do not fit, -for the same underlying reason: **they need a device the manager never assigned.** +Earlier versions of this question listed symptoms — "`ps2-bus` ignores its `argv[1]`", +"discovery has no assignment" — which hid that they are the same fact. -- **`ps2-bus` ignores its `argv[1]` entirely.** It finds the controller by walking the - table for `PNP0303`, and then claims a *second* device — the `PNP0F13` mouse node — - which it also finds itself. So it holds two devices and was assigned at most one, and - `system_spawn` carries one. -- **discovery** is spawned `addDriver("discovery", no_device, false)` and claims the - `acpi-tables` node it locates itself, because it is what produces the device tree; - there is nothing to assign at that point. +| Pattern | Who | How it gets its device | +|---|---|---| +| Per-device driver | `pci-bus`, `usb-xhci-bus`, `virtio-gpu`, `usb-hid`, `usb-storage` | assigned one id, one instance per device — **all converted** | +| Singleton that finds its own | `ps2-bus`, discovery | spawned once with `no_device`, walks the table itself | -Three shapes of answer, none written down: +The second is deliberate, not an oversight. `onChildAdded` says so: -1. **The manager assigns every device a driver needs.** For `ps2-bus` it would have to - understand that one driver serves both `PNP0303` and `PNP0F13` — `devices.csv` maps - both to it already, so the manager may simply be spawning two instances today where - the driver expects one. Worth checking before designing. -2. **A driver asks for a device over the protocol** — a `request(device)` verb the - manager answers by transferring. General, and it reintroduces a window, though only - for a device the driver asks for after it is running. -3. **The bootstrap is exempt**: discovery keeps claiming, and D6 permits a claim of a - device nobody holds *only* for the discovery role. Narrow and honest, but it is an - exception in the exact place an exception is most expensive. +> An hid-matched driver (ps2-bus) is a singleton that finds its own devices once +> spawned — spawn it once, no device assignment. -**D6 stays blocked** until this is answered — closing `device_claim` with these two -still depending on it would stop the machine booting. +`device_claim` is the mechanism that makes that pattern work. **D6 removes it and puts +nothing in its place**, which is the whole of the blockage. + +The manager is not ignorant: it matched *both* `PNP0303` and `PNP0F13` to `ps2-bus` and +chose not to assign either, so it already knows which devices a singleton wants. An +answer is therefore in reach — hand a singleton each matching device as it matches. The +cost: only the first can ride the spawn, so later ones arrive while the driver is +running, and `ps2-bus` enumerates once at startup and would have to tolerate that. + +**The decision: do singletons stop being singletons — one instance per device, like +everything else — or does the system keep a second acquisition path for them?** The +first is uniform and costs a rewrite of `ps2-bus`'s startup. The second keeps a +mechanism whose only remaining users are two drivers, and every exemption in an +authority model is somewhere the model does not hold. + +Nothing else blocks D6. Both invented ceilings are already gone, so this is about +closing the claiming hole, not about a number. ### Settled 2026-08-08: the grant rides `system_spawn` (D0) diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index 286e370..2d3acff 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -53,22 +53,6 @@ const heap = @import("heap.zig"); /// found against registered) const maximum_devices_per_registrar = 4096; -/// Cap on children a single parent may have. A zero-resource child (legal — a USB -/// device is addressed through its controller, not by MMIO) sidesteps the containment -/// check, so without a bound a process that claimed one device could loop -/// `device_register` and exhaust the whole table, permanently denying it to every other -/// driver. This bounds the blast radius of one claim; a real quota (and a -/// `device_release` to reclaim on exit) is future work — see docs/driver-model.md. -/// -/// bound: children one claimed parent may register — in practice every PCI function on -/// the machine, since pci-bus registers them all under the one host bridge -/// decided-by: hardware -/// protects: the shared device table, against a driver looping device_register — but -/// it is a proxy for an authorisation the kernel does not perform, since any -/// process may claim any unclaimed device (docs/bounds-track-plan.md phase 2) -/// at-limit: refuse — ECHILDREN, distinct from a full table -/// observed-by: pci-bus logs the reason per refused function, and warns at end of scan -const maximum_children_per_parent = 16; /// The table, grown on demand from the kernel heap. **There is no ceiling**: how many /// devices a machine has is the machine's business, and no specification bounds it, so @@ -368,7 +352,7 @@ pub const RegisterError = error{ NoSuchParent, // no device with that id NotYourParent, // that device exists but this task has not claimed it TooManyResources, // the descriptor declares more resources than one device may hold - TooManyChildren, // this parent is at maximum_children_per_parent + TooManyChildren, // the caller is at its per-registrar allowance NotContained, // a child resource escapes its parent's window }; @@ -459,14 +443,6 @@ fn existingChild(parent_id: u64, descriptor: *const device_abi.DeviceDescriptor) return null; } -/// Number of devices currently recorded with `parent_id` as their parent. -fn childCount(parent_id: u64) usize { - var n: usize = 0; - for (devices[0..count]) |d| { - if (d.parent == parent_id) n += 1; - } - return n; -} /// Publish `descriptor` as a child of `parent_id`, on behalf of `owner`. Returns the new /// device id. The child is left **unclaimed**, so another process (a class driver) @@ -497,7 +473,6 @@ pub fn register(parent_id: u64, owner: u32, descriptor: *const device_abi.Device // below). if (existingChild(parent_id, descriptor)) |existing_id| return existing_id; - if (childCount(parent_id) >= maximum_children_per_parent) return error.TooManyChildren; // The allowance is charged to whoever is registering, so a driver in a loop // exhausts its own and every other driver carries on. There is no machine-wide // ceiling any more: the table grows. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 9059ae2..9f4ee11 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -4037,34 +4037,33 @@ fn containmentTest() void { check("re-registering an identical child returns the same id", again != 0 and again == good); check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1); - // Fill the parent to its child cap with distinct children (same window, different - // identity — the match is on identity, so each is a new device). + // **There is no per-parent cap.** `maximum_children_per_parent = 16` is gone: it + // was written to stop a driver looping `device_register` and exhausting a shared + // table, and there is no shared table to exhaust — the table grows, and each + // registrar has its own allowance, so a runaway costs only itself. The cap never + // bounded a determined caller anyway (16 per parent, but nothing stopped it + // claiming more parents); what it reliably did was refuse a real PCI bus with more + // than 16 functions, which is how an AMD Ryzen booted with no USB and no storage. + // + // 64 children under one parent — four times the old ceiling. var filled: u32 = 0; - var capped = false; + var registered_children: u32 = 0; while (filled < 64) : (filled += 1) { var name: [4]u8 = .{ 'k', 0, 0, 0 }; name[1] = '0' + @as(u8, @intCast(filled / 10)); name[2] = '0' + @as(u8, @intCast(filled % 10)); var extra = childDescriptor(name[0..3], parent_window.start, 0x20); - _ = devices_broker.register(parent_id, me, &extra) catch |err| { - capped = err == error.TooManyChildren; - break; - }; + if (devices_broker.register(parent_id, me, &extra)) |_| { + registered_children += 1; + } else |_| break; } - check("the parent reaches its child cap (TooManyChildren)", capped); + check("one parent takes far more children than the old cap allowed", registered_children == 64); - // The regression this ordering exists for: **a re-registration consumes no slot, - // so a full parent must not refuse one.** A crashed bus driver is restarted by its - // supervisor and re-registers everything it rediscovers; when the cap was checked - // before the identity match, the restart was refused its own devices and the - // machine degraded a little more on every crash. + // Idempotency still holds, and still matters: a crashed bus driver is restarted and + // re-registers everything it rediscovers, which must return the ids it had before + // rather than duplicate them. const readmitted = devices_broker.register(parent_id, me, &fits) catch 0; - check("a full parent still re-admits an identical child", readmitted != 0 and readmitted == good); - - // ...and the cap is genuinely still in force for anything new. - var novel = childDescriptor("knew", parent_window.start, 0x20); - const still_capped = if (devices_broker.register(parent_id, me, &novel)) |_| false else |err| err == error.TooManyChildren; - check("a full parent still refuses a new child", still_capped); + check("re-registering an identical child still returns its id", readmitted != 0 and readmitted == good); // The table itself has no ceiling: it grows. The old `maximum_devices = 64` was a // guess about someone else's computer, and one driver's enumeration starved every