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:
+35
-30
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user