kernel: device_transfer — you may give away what you hold

The mechanism behind delegation, which device-manager.md named as the step
after hello: the device manager claims what discovery seeded and hands each
device to the driver it matched, so assignment stops being
first-come-first-served.

It is a MOVE, not a copy. A claim is exclusive (driver-model.md, invariant
1), so the giver stops holding the device the instant the receiver starts.
That is why this is a new syscall rather than the M13 capability path, where
a passed handle is shared refcounted — exclusivity cannot be expressed that
way.

The kernel's whole rule is that you may give away what you hold. It has no
notion of which task is the device manager and deliberately gains none: a
binary name inside the kernel is not something that cannot safely live in
user space. A recipient that does not exist is refused, because a device
moved to nobody would be unreachable for the rest of the boot — nothing
un-holds a device but task death.

Three errnos, each naming its own rule: ENODEV no such device, EPERM you do
not hold it, ESRCH no such recipient.

Nothing uses it yet. The five claimants move across one at a time in D4-D5,
so the suite stays green throughout and a regression names the driver that
caused it.

Ten assertions, verified to discriminate: removing the ownership check flips
four of them, including the giveaway that an illegal transfer then blocks
the legitimate claim behind it.

Suite 116 -> 117.
This commit is contained in:
Daniel Samson
2026-08-08 17:11:59 +01:00
parent 547d0ec46b
commit 3111c7c5e6
7 changed files with 148 additions and 1 deletions
+36
View File
@@ -323,6 +323,42 @@ pub const ClaimError = error{
AlreadyClaimed, // a live task already owns it
};
/// Why a `transfer` was refused.
pub const TransferError = error{
NoSuchDevice, // no device with that id
NotHeld, // the caller does not hold it — you may only give away what you have
};
/// The errno a refused `transfer` returns to ring 3. (`ESRCH` — no such recipient — is
/// raised by the caller in system/kernel/process.zig, which is what can see the task
/// table.)
pub fn transferErrnoOf(e: TransferError) i64 {
return switch (e) {
error.NoSuchDevice => abi.ENODEV,
error.NotHeld => abi.EPERM,
};
}
/// Move device `id` from `from` to `to`. **A move, not a copy**: a claim is exclusive
/// (driver-model.md, invariant 1), so the giver stops holding it the moment the
/// receiver starts.
///
/// This is the mechanism behind delegation — the device manager claims what firmware
/// discovery seeded and passes each device to the driver it matched, which replaces
/// first-come-first-served `device_claim` with policy
/// (docs/device-driver-development/device-manager.md). The kernel checks only that the
/// caller holds the device: *you may give away what you have*. It knows nothing about
/// which task is the manager, and needs to know nothing.
///
/// Note this is deliberately NOT the M13 capability-passing path, which shares a handle
/// refcounted — a copy. Exclusivity cannot be expressed that way.
pub fn transfer(id: u64, from: u32, to: u32) TransferError!void {
if (id >= count) return error.NoSuchDevice;
const holder = claimed[@intCast(id)] orelse return error.NotHeld;
if (holder != from) return error.NotHeld;
claimed[@intCast(id)] = to;
}
/// The errno a refused `claim` returns to ring 3. (`ECONFINE` — the claim stood but
/// the IOMMU would not confine the device — is raised by the caller in
/// system/kernel/process.zig, which is what rolls the claim back.)
+24
View File
@@ -249,6 +249,7 @@ fn system_call(state: *architecture.CpuState) void {
.irq_bind => systemIrqBind(state),
.irq_ack => systemIrqAck(state),
.device_register => systemDeviceRegister(state),
.device_transfer => systemDeviceTransfer(state),
.system_spawn => systemSpawn(state),
.dma_alloc => systemDmaAlloc(state),
.dma_free => systemDmaFree(state),
@@ -440,6 +441,29 @@ fn systemDeviceClaim(state: *architecture.CpuState) void {
architecture.setSystemCallResult(state, 0);
}
/// device_transfer(device_id, task_id) -> 0/-errno: give a device you hold to another
/// task. The mechanism behind delegation — the device manager claims what discovery
/// seeded and hands each device to the driver it matched, so assignment stops being
/// first-come-first-served (docs/os-development/device-authority.md).
///
/// The kernel's whole rule is *you may give away what you hold*. It has no notion of
/// which task is the device manager, and deliberately gains none: a binary name in the
/// kernel is not something that cannot safely live in user space.
fn systemDeviceTransfer(state: *architecture.CpuState) void {
const device_id = architecture.systemCallArg(state, 0);
const task_id: u32 = @truncate(architecture.systemCallArg(state, 1));
const flags = sync.enter();
defer sync.leave(flags);
// The recipient must exist, or the device would be moved to nobody and become
// unreachable for the rest of the boot — no path un-holds a device but task death.
if (scheduler.taskByIdLocked(task_id) == null) return failErr(state, ipc.ESRCH);
devices_broker.transfer(device_id, scheduler.current().id, task_id) catch |e|
return failErr(state, devices_broker.transferErrnoOf(e));
architecture.setSystemCallResult(state, 0);
}
/// mmio_map(device_id, resource_index) -> virtual_address: map a claimed device's MMIO window into
/// this address space (strong-uncacheable) and return the register base address.
/// The claim is the capability — a process can only map hardware it owns.
+59
View File
@@ -261,6 +261,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
containmentTest();
} else if (eql(case, "apertures")) {
apertureTest();
} else if (eql(case, "device-transfer")) {
deviceTransferTest(boot_information);
} else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) {
@@ -4020,6 +4022,63 @@ fn containmentTest() void {
}
/// A minimal child descriptor with one memory resource, for the containment test.
/// Delegation's mechanism. A claim is exclusive (driver-model.md, invariant 1), so
/// handing a device on is a **move**: the giver stops holding it the instant the
/// receiver starts. That is why this is not the M13 capability path, which shares a
/// handle refcounted.
///
/// The rule the kernel enforces is the whole of it: *you may give away what you hold*.
/// It has no idea which task is the device manager and needs none
/// (docs/os-development/device-authority.md).
fn deviceTransferTest(boot_information: *const boot_handoff.BootInformation) void {
var buffer: [8]device_abi.DeviceDescriptor = undefined;
check("the device tree is seeded", devices_broker.enumerate(&buffer) >= 2);
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0;
check("supervised child spawned", child != 0);
// You may only give away what you hold — so an unheld device cannot be moved at all,
// which is what stops a transfer being a back door around claiming.
const unheld = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld;
check("an unheld device cannot be transferred", unheld);
check("claimed device 0", claimOk(0, me));
// The move itself.
const moved = if (devices_broker.transfer(0, me, child)) |_| true else |_| false;
check("the holder may transfer", moved);
const holder = devices_broker.ownerOf(0) orelse 0;
check("the receiver holds it", holder == child);
check("the giver does not", holder != me);
// Having given it away, the giver cannot give it again. This is the assertion that
// makes it a move rather than a copy.
const again = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld;
check("a former holder cannot transfer again", again);
// Nor may a stranger move a device it never held.
const stranger = if (devices_broker.transfer(0, 9999, me)) |_| false else |e| e == error.NotHeld;
check("a stranger cannot transfer another task's device", stranger);
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);
devices_broker.releaseAllOwnedBy(child);
devices_broker.releaseAllOwnedBy(me);
result();
}
/// PCI host-bridge apertures are derived from the *holes* in the firmware memory map,
/// and a registered BAR must fall inside one. So the invariant is not "we find the
/// holes" but "an aperture never covers memory the firmware described" — an aperture