kernel: maximum_children_per_parent is gone

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.
This commit is contained in:
Daniel Samson
2026-08-08 21:12:20 +01:00
parent 3ae541214f
commit 7d8aa51234
3 changed files with 54 additions and 75 deletions
+35 -30
View File
@@ -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 | | 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 | | 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 | | 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 | | 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 **Run 2 stops, blocked on one question.** Landed: D0, D1, D2, D4, D8, D9, and D5 for
claimants (`usb-xhci-bus`, `pci-bus`, `virtio-gpu`). `maximum_devices` no longer exists, three of five claimants (`usb-xhci-bus`, `pci-bus`, `virtio-gpu`). Suite 118/118.
delegation is atomic with the spawn, and the suite is 118/118.
Blocked: **D6 and D8 on question 9** (`ps2-bus` needs two devices and ignores its **Both invented ceilings are gone.** `maximum_devices` and
assignment; discovery needs a node nobody assigns), and **D7 on question 8**. So `maximum_children_per_parent` no longer exist, and delegation is atomic with the spawn.
`maximum_children_per_parent` — the second invented number — is one answer away.
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 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 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 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. 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, Earlier versions of this question listed symptoms — "`ps2-bus` ignores its `argv[1]`",
for the same underlying reason: **they need a device the manager never assigned.** "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 | Pattern | Who | How it gets its device |
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 | Per-device driver | `pci-bus`, `usb-xhci-bus`, `virtio-gpu`, `usb-hid`, `usb-storage` | assigned one id, one instance per device — **all converted** |
`system_spawn` carries one. | Singleton that finds its own | `ps2-bus`, discovery | spawned once with `no_device`, walks the table itself |
- **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.
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 > An hid-matched driver (ps2-bus) is a singleton that finds its own devices once
understand that one driver serves both `PNP0303` and `PNP0F13` — `devices.csv` maps > spawned — spawn it once, no device assignment.
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.
**D6 stays blocked** until this is answered — closing `device_claim` with these two `device_claim` is the mechanism that makes that pattern work. **D6 removes it and puts
still depending on it would stop the machine booting. 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) ### Settled 2026-08-08: the grant rides `system_spawn` (D0)
+1 -26
View File
@@ -53,22 +53,6 @@ const heap = @import("heap.zig");
/// found against registered) /// found against registered)
const maximum_devices_per_registrar = 4096; 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 /// 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 /// 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 NoSuchParent, // no device with that id
NotYourParent, // that device exists but this task has not claimed it NotYourParent, // that device exists but this task has not claimed it
TooManyResources, // the descriptor declares more resources than one device may hold 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 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; 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 /// 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) /// 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). // below).
if (existingChild(parent_id, descriptor)) |existing_id| return existing_id; 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 // 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 // exhausts its own and every other driver carries on. There is no machine-wide
// ceiling any more: the table grows. // ceiling any more: the table grows.
+18 -19
View File
@@ -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 an identical child returns the same id", again != 0 and again == good);
check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1); check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1);
// Fill the parent to its child cap with distinct children (same window, different // **There is no per-parent cap.** `maximum_children_per_parent = 16` is gone: it
// identity — the match is on identity, so each is a new device). // 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 filled: u32 = 0;
var capped = false; var registered_children: u32 = 0;
while (filled < 64) : (filled += 1) { while (filled < 64) : (filled += 1) {
var name: [4]u8 = .{ 'k', 0, 0, 0 }; var name: [4]u8 = .{ 'k', 0, 0, 0 };
name[1] = '0' + @as(u8, @intCast(filled / 10)); name[1] = '0' + @as(u8, @intCast(filled / 10));
name[2] = '0' + @as(u8, @intCast(filled % 10)); name[2] = '0' + @as(u8, @intCast(filled % 10));
var extra = childDescriptor(name[0..3], parent_window.start, 0x20); var extra = childDescriptor(name[0..3], parent_window.start, 0x20);
_ = devices_broker.register(parent_id, me, &extra) catch |err| { if (devices_broker.register(parent_id, me, &extra)) |_| {
capped = err == error.TooManyChildren; registered_children += 1;
break; } 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, // Idempotency still holds, and still matters: a crashed bus driver is restarted and
// so a full parent must not refuse one.** A crashed bus driver is restarted by its // re-registers everything it rediscovers, which must return the ids it had before
// supervisor and re-registers everything it rediscovers; when the cap was checked // rather than duplicate them.
// before the identity match, the restart was refused its own devices and the
// machine degraded a little more on every crash.
const readmitted = devices_broker.register(parent_id, me, &fits) catch 0; 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); check("re-registering an identical child still returns its id", 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);
// The table itself has no ceiling: it grows. The old `maximum_devices = 64` was a // 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 // guess about someone else's computer, and one driver's enumeration starved every