establishment: block stops being a name, and enumerate learns to page

P2 of docs/establishment-planes-plan.md. usb-storage serves nameless — one
process per stick cannot share an exclusive bind, and a second stick used to
die silently on -EBUSY before ever helloing. Its one hello now moves both
directions at once: the block-serving endpoint up, its controller channel
down. fat finds its volume through the manager — a new `.consumer` role asks
for the channel of the driver BOUND TO a device (distinct from the device's
reporter), found by enumerating the tree for the mass-storage identity.
fat stays single-volume; the boot-volume-by-content choice is M21.

The conversion immediately caught a live truncation of exactly the audit's
shape: ChildEntry grew to 32 bytes, one enumerate reply holds ~7, and a real
tree carries a dozen ACPI nodes before the first USB child — the storage
entry silently never fit (the protocol comment already said "paging joins
the protocol if a tree ever outgrows one packet"). enumerate is now paged:
Header.target is the start cursor, a short page is the end; device-list's
page-0 read is unchanged.

Grant rows move with the code: the block bind and fat's block open die, fat
gains open device-manager. Gate: 18 cases green including the registry
trio, device-list, and both IOMMU storage variants.
This commit is contained in:
Daniel Samson
2026-08-09 12:24:25 +01:00
parent d603d40b5c
commit 1d7850239d
10 changed files with 155 additions and 61 deletions
@@ -99,10 +99,19 @@ fn identityFromReport(report: device_manager_protocol.ChildAdded) registry.Ident
/// Whether some driver entry already serves registered device `device_id` —
/// a re-report after a bus restart must not spawn a second instance.
fn driverForDevice(device_id: u64) bool {
return driverEntryForDevice(device_id) != null;
}
/// The driver entry BOUND TO a device — matched and spawned for it. Distinct
/// from the device's reporter: a mouse's provider is the bus that reported it
/// (lineage via `children[].reporter`), while a volume's provider is the
/// storage driver spawned FOR it — which is what a `.consumer` hello asks for.
fn driverEntryForDevice(device_id: u64) ?*Driver {
if (device_id == device_manager_protocol.no_device) return null;
for (&drivers) |*driver| {
if (driver.used and driver.device_id == device_id) return true;
if (driver.used and driver.device_id == device_id) return driver;
}
return false;
return null;
}
// --- supervision -------------------------------------------------------------
@@ -509,6 +518,24 @@ fn onHello(_: void, invocation: Invocation(device_manager_protocol.Hello), _: An
std.log.info("refused hello (version {d}) from process {d}", .{ invocation.request.version, invocation.sender });
return -envelope.EPROTO;
}
// A consumer is not a spawned driver: no entry, no deadline, no state —
// just establishment. It asks for the channel of the driver BOUND TO its
// target (fat asking for its volume's block provider). No channel is a
// retryable ack, exactly as for a device-role consumer.
//
// The residual, stated plainly: any process granted `open device-manager`
// can ask. The grant rows are the gate today, as they were when the block
// name was open-granted; a finer per-channel policy belongs to the same
// future as the spawn capability (device-authority.md).
if (invocation.request.role == @intFromEnum(device_manager_protocol.Role.consumer)) {
if (invocation.request.wants_channel != 0) {
if (driverEntryForDevice(invocation.target)) |provider| {
if (provider.endpoint) |serving| service.replyWithCapability(serving);
}
}
return 0;
}
const driver = driverByProcess(invocation.sender) orelse {
std.log.info("hello from unknown process {d}", .{invocation.sender});
return -envelope.EPERM;
@@ -664,14 +691,24 @@ fn onChildRemoved(_: void, invocation: Invocation(device_manager_protocol.ChildR
/// The reserved `enumerate` verb: the mirror, one `ChildEntry` per known child,
/// packed into the reply's tail. How many arrived is the reply's own length —
/// `Status.len` — so no count header is spent saying it twice.
fn onEnumerate(_: void, _: Invocation(void), answer: Answer(void)) isize {
fn onEnumerate(_: void, invocation: Invocation(void), answer: Answer(void)) isize {
const entry_size = @sizeOf(device_manager_protocol.ChildEntry);
const tail = answer.tail();
// `target` is the page cursor: skip that many known children first. One
// reply holds only a handful of entries, and a real tree (a dozen ACPI
// nodes before the first USB child) outgrew one packet — the storage
// child silently never fit, which is precisely the truncation shape the
// bounds audit exists to forbid. A caller pages until a short page.
var skip = invocation.target;
var written: usize = 0;
for (&children) |*child| {
if (!child.used) continue;
if (skip > 0) {
skip -= 1;
continue;
}
if (written + entry_size > tail.len) break;
const entry = device_manager_protocol.ChildEntry{ .parent = child.parent, .bus_address = child.bus_address, .identity = child.identity };
const entry = device_manager_protocol.ChildEntry{ .parent = child.parent, .bus_address = child.bus_address, .identity = child.identity, .device_id = child.device_id };
@memcpy(tail[written..][0..entry_size], std.mem.asBytes(&entry));
written += entry_size;
}