From 1a1d92cba9b95d1e0dde49aae22756b8eabe6eac Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:21:25 +0100 Subject: [PATCH] =?UTF-8?q?kernel:=20a=20grant=20is=20a=20loan=20=E2=80=94?= =?UTF-8?q?=20a=20dead=20borrower=20returns=20the=20device=20to=20its=20le?= =?UTF-8?q?nder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a driver dies, a device it was *given* now goes back to whoever lent it, rather than to nobody. The device manager gets its hardware back the instant a driver dies and hands it to the replacement, with no window in between. That window was real: the kernel released the claim to no one and the manager re-claimed first-come, so every driver restart reopened the hole this run is closing. It also becomes load-bearing at the next step — once claim refuses a device that has a giver, releasing to nobody would strand a dead driver's hardware permanently, because nobody could ever take it again. A dead lender is no lender: the claim and the giver clear together, so a device is never owed to a ghost. A device nobody lent is released outright, exactly as before. The broker cannot see the task table, so liveness arrives through the same hook idiom the scheduler already uses. Null means assume dead, so a kernel built without the hook frees claims rather than handing them to a ghost. A stale binary nearly passed as proof for the third time this session: the first discrimination patch left `alive` unused, the build failed with three errors, and the old binary reported every assertion passing. Checking the build before reading results is what caught it. Suite 118/118. --- docs/bounds-track-plan.md | 2 +- system/kernel/devices-broker.zig | 36 +++++++++++++++++++++++++++++--- system/kernel/process.zig | 9 ++++++++ system/kernel/tests.zig | 12 +++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index 8a893a0..49e6968 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -125,7 +125,7 @@ Two things fall out rather than being special-cased: | Step | What | |---|---| | E1 | Record a giver per device; `device_transfer` and the spawn grant set it — **done** | -| E2 | On task death a device reverts to its giver if alive, else its claim clears | +| E2 | On task death a device reverts to its giver if alive, else its claim clears — **done** | | E3 | `device_claim` refuses a device that has a giver | | E4 | The manager claims every resource-bearing device at boot, so nothing is left takeable | | E5 | The attacker fixture gains the claim half it has been waiting for since D2 | diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index 0b2b10e..7b16b73 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -272,11 +272,41 @@ pub fn ownerOf(id: u64) ?u32 { /// hardware again (docs/process-lifecycle.md iron rule 1: cleanup is the kernel's /// job). The devices stay in the table — they describe hardware, which did not go /// away — only their ownership clears. +/// Whether a task is still alive, injected by the process layer (which owns the task +/// table) the same way the scheduler's other hooks are. Null means "assume not", so a +/// kernel built without it clears claims rather than handing them to a ghost. +pub var task_alive_hook: ?*const fn (u32) bool = null; + +fn alive(task: u32) bool { + const hook = task_alive_hook orelse return false; + return hook(task); +} + pub fn releaseAllOwnedBy(owner: u32) void { - for (claimed[0..count]) |*slot| { - if (slot.*) |o| { - if (o == owner) slot.* = null; + for (claimed[0..count], 0..) |*slot, id| { + const holder = slot.* orelse continue; + if (holder != owner) continue; + + // **A grant is a loan.** A device this task was *given* goes back to whoever + // lent it, not to nobody — so the device manager gets its hardware back the + // instant a driver dies, and hands it to the replacement. + // + // Without this the kernel released the claim to no one and the manager + // re-claimed first-come, so every driver restart reopened the window this + // rule closes. And once `claim` refuses a device that has a giver, releasing + // to nobody would strand it: no one could ever take it again. + // + // A dead lender is no lender: clear the claim and the giver together, so the + // device is genuinely free rather than owed to a ghost. + if (giver[id]) |lender| { + if (alive(lender)) { + slot.* = lender; + giver[id] = null; // returned; it is the lender's own again, not on loan + continue; + } + giver[id] = null; } + slot.* = null; } } diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 756e783..e167312 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -190,6 +190,15 @@ pub fn init() void { scheduler.timer_tick_hook = timerSweepLocked; scheduler.group_exit_hook = groupExitLocked; scheduler.space_mapping_release_hook = dropSpaceMappingHook; + // The broker owns devices; the process layer owns the task table. It asks whether a + // lender is still alive before handing a dead driver's device back to it. + devices_broker.task_alive_hook = taskAliveLocked; +} + +/// Whether `id` names a live task. The broker calls this through its hook when +/// deciding if a dead holder's device can go back to the task that lent it. +fn taskAliveLocked(id: u32) bool { + return scheduler.taskByIdLocked(id) != null; } /// Return -1 (as an unsigned bit pattern) in the system_call result register. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 1f84bea..7681ce4 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -4154,8 +4154,20 @@ fn deviceTransferTest(boot_information: *const boot_handoff.BootInformation) voi const absent = if (devices_broker.transfer(9999, me, child)) |_| false else |e| e == error.NoSuchDevice; check("a device that does not exist is refused", absent); + // **A grant is a loan.** The child holds device 0 and `me` lent it, so the child's + // death must hand it back rather than release it to nobody. That is what lets the + // device manager re-delegate to a restarted driver — and, once `claim` refuses a + // device that has a giver, it is the only thing that stops a dead driver's hardware + // being stranded forever. + check("the child still holds the lent device", devices_broker.ownerOf(0) == child); devices_broker.releaseAllOwnedBy(child); + check("the borrower's death returns the device to its lender", devices_broker.ownerOf(0) == me); + check("and it is no longer on loan", devices_broker.giverOf(0) == null); + + // A device with no lender still simply frees on death, as it always did. + check("claimed a second device with no lender", claimOk(parentless, me)); devices_broker.releaseAllOwnedBy(me); + check("a device nobody lent is released outright", devices_broker.ownerOf(parentless) == null); result(); }