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
+4 -25
View File
@@ -7,10 +7,8 @@
//! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size
//! limit — the same handoff usb-storage uses toward the controller.
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const time = @import("time");
const block_protocol = @import("block-protocol");
const Protocol = block_protocol.Protocol;
@@ -75,26 +73,7 @@ pub const Device = struct {
}
};
/// One open attempt, no waiting — for a server that retries on its own
/// timer (the fat service) instead of blocking its harness in here.
pub fn tryOpen() ?Device {
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
return null;
}
/// Open `/protocol/block`, retrying generously while the USB storage chain
/// (controller reset, enumeration, mass-storage bring-up) comes up.
pub fn open() ?Device {
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and
// enumeration, mass-storage bring-up) must complete first, which can take
// tens of seconds under emulation.
var attempts: usize = 0;
// 30 s covers the slowest observed healthy chain (a flaky QEMU enumeration
// completed at ~24 s); a machine whose stick genuinely failed setup should
// not sit a further minute pretending otherwise.
while (attempts < 600) : (attempts += 1) {
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
time.sleepMillis(50);
}
return null;
}
// There is deliberately no open-by-name here: `block` is not a registry name.
// One storage process serves each volume, and a consumer receives its volume's
// channel from the device manager (establishment by lineage, communication.md
// "Establishment: two planes"), then wraps it: `block.Device{ .endpoint = c }`.
+15 -10
View File
@@ -270,18 +270,21 @@ pub const Exchange = struct {
};
/// A consumer's whole establishment step: hello until the channel to this
/// device's provider arrives. The manager acks a hello whose provider is not
/// there yet (mid-restart, re-report on the way) with no channel — retryable
/// by design — so this re-hellos on the ONE manager handle, on the same
/// cadence the old name lookup used, and gives up on a refusal or a vanished
/// manager. Re-hello is benign: the manager just re-marks the entry running.
pub fn helloForChannel(role: Role, device_id: u64) ?ipc.Handle {
const first = helloExchange(role, device_id, null, true) orelse return null;
/// device's provider arrives. `serving` (a provider-and-consumer like
/// usb-storage: block endpoint up, bus channel down) rides the FIRST exchange
/// only — the manager keeps it, so retries need not resend it. The manager
/// acks a hello whose provider is not there yet (mid-restart, re-report on
/// the way) with no channel — retryable by design — so this re-hellos on the
/// ONE manager handle, on the same cadence the old name lookup used, and
/// gives up on a refusal or a vanished manager. Re-hello is benign: the
/// manager just re-marks the entry running.
pub fn helloForChannel(role: Role, device_id: u64, serving: ?ipc.Handle) ?ipc.Handle {
const first = helloExchange(role, device_id, serving, true) orelse return null;
if (first.channel) |bus| return bus;
var attempts: u32 = 0;
while (attempts < lookup_attempts) : (attempts += 1) {
time.sleepMillis(lookup_pause_ms);
const again = exchangeOn(first.manager, role, device_id, null, true) orelse return null;
const again = helloOn(first.manager, role, device_id, null, true) orelse return null;
if (again.channel) |bus| return bus;
}
std.log.info("no provider channel for device {d}", .{device_id});
@@ -303,12 +306,14 @@ pub fn helloExchange(role: Role, device_id: u64, serving: ?ipc.Handle, want_chan
std.log.info("no device manager to hello", .{});
return null;
};
return exchangeOn(manager, role, device_id, serving, want_channel);
return helloOn(manager, role, device_id, serving, want_channel);
}
/// One hello on an already-open manager handle — the exchange without the
/// lookup, so a retry loop never spends a handle-table slot per attempt.
fn exchangeOn(manager: ipc.Handle, role: Role, device_id: u64, serving: ?ipc.Handle, want_channel: bool) ?Exchange {
/// Public for parties that keep their own manager handle across a long retry
/// cadence (fat polls for its volume on a timer).
pub fn helloOn(manager: ipc.Handle, role: Role, device_id: u64, serving: ?ipc.Handle, want_channel: bool) ?Exchange {
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
const framed = device_manager_protocol.Protocol.encodeRequest(
.hello,
@@ -63,6 +63,11 @@ pub const Role = enum(u8) {
bus = 1,
/// Serves one device, reached through a bus's transfer protocol.
device = 2,
/// Not a spawned driver at all: a party asking for the channel of the
/// driver BOUND TO the target device (a service consuming a driver-layer
/// contract — fat asking for its volume's block provider). No deadline, no
/// state, no driver entry; just establishment by lineage.
consumer = 3,
};
// --- the per-operation request parts ----------------------------------------
@@ -160,10 +165,18 @@ pub const ChildEntry = extern struct {
parent: u64,
bus_address: u64,
identity: u64,
/// The kernel device id this child was registered as (`no_device` for an
/// unregistered leaf) — what a `.consumer` hello names as its target to be
/// routed to the driver bound to this child.
device_id: u64,
};
/// How many `ChildEntry` records one `enumerate` reply can carry. Paging joins
/// the protocol if a tree ever outgrows one packet.
/// How many `ChildEntry` records one `enumerate` reply can carry. The verb is
/// PAGED: the request's `Header.target` is the start index (skip that many
/// known children), and a short or empty page means the tree is exhausted — a
/// real tree outgrew one packet the day ACPI reported a dozen nodes before
/// the first USB child, and an unpaged reply silently truncated exactly the
/// entries a consumer was looking for.
pub const entries_per_reply: usize = (envelope.packet_maximum - envelope.prefix_size) / @sizeOf(ChildEntry);
pub const Protocol = envelope.Define(.{
+10 -10
View File
@@ -63,12 +63,11 @@
/system/services/discovery, /system/services/device-manager, bind, power
# --- the drivers, which the device manager spawns ---------------------------
# usb-transfer has NO bind row: several bus processes provide it (one per
# controller), so it is never a registry name — consumers get their controller's
# channel from the device manager's hello, routed by lineage (communication.md
# "Establishment: two planes, one namespace").
# usb-transfer and block have NO bind rows: several processes provide each (one
# per controller, one per volume), so neither is ever a registry name —
# consumers get their provider's channel from the device manager's hello,
# routed by lineage (communication.md "Establishment: two planes").
/system/drivers/ps2-bus, /system/services/device-manager, bind, ps2-bus
/system/drivers/usb-storage, /system/services/device-manager, bind, block
/system/drivers/virtio-gpu, /system/services/device-manager, bind, scanout
# --- the same providers when the kernel test harness starts them directly ---
@@ -95,11 +94,12 @@
# ============================================================================
# --- init's own services ----------------------------------------------------
# fat reaches the block device behind the volume it mounts; the compositor
# reaches the scanout its driver announced, its own endpoint (the mouse-listener
# thread opens /protocol/display like any other client — threads share no
# handles), and the input stream that moves the cursor.
/system/services/fat, /system/services/init, open, block
# fat reaches the device manager to be routed to its volume's block provider
# (block is not a name — see the bind section); the compositor reaches the
# scanout its driver announced, its own endpoint (the mouse-listener thread
# opens /protocol/display like any other client — threads share no handles),
# and the input stream that moves the cursor.
/system/services/fat, /system/services/init, open, device-manager
/system/services/display, /system/services/init, open, scanout
/system/services/display, /system/services/init, open, display
/system/services/display, /system/services/init, open, input
Can't render this file because it contains an unexpected character in line 12 and column 15.
+1 -1
View File
@@ -77,7 +77,7 @@ pub fn main(init: process.Init) void {
// The hello both meets the spawn deadline and brings back the channel to
// THIS device's controller — routed by lineage, never found by name.
const bus = device_manager.helloForChannel(.device, device_id) orelse return;
const bus = device_manager.helloForChannel(.device, device_id, null) orelse return;
var device = usb.open(bus, device_id) orelse {
std.log.info("could not open device {d}", .{device_id});
return;
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn main(init: process.Init) void {
// The hello both meets the spawn deadline and brings back the channel to
// THIS device's controller — routed by lineage, never found by name.
const bus = device_manager.helloForChannel(.device, device_id) orelse return;
const bus = device_manager.helloForChannel(.device, device_id, null) orelse return;
var device = usb.open(bus, device_id) orelse {
std.log.info("could not open device {d}", .{device_id});
return;
+10 -5
View File
@@ -82,10 +82,13 @@ fn transact(cdb: []const u8, direction_in: bool, data_physical: u64, data_length
var bring_up_failed = false;
fn initialise(endpoint: ipc.Handle) bool {
_ = endpoint;
// The hello both meets the spawn deadline and brings back the channel to
// THIS device's controller — routed by lineage, never found by name.
const bus = device_manager.helloForChannel(.device, device_id) orelse return false;
// One hello, both directions: the block-serving endpoint goes UP (the
// manager routes fat's consumer hello here — this driver serves one
// volume, one process per stick, so `block` is never a registry name),
// and the channel to THIS device's controller comes DOWN, routed by
// lineage. A second stick used to die silently on the exclusive bind;
// now every instance is reachable through its lineage.
const bus = device_manager.helloForChannel(.device, device_id, endpoint) orelse return false;
device = usb.open(bus, device_id) orelse {
std.log.info("could not open device {d}", .{device_id});
return false;
@@ -224,8 +227,10 @@ pub fn main(init: process.Init) void {
std.log.info("malformed device id '{s}'", .{argument});
return;
};
// No `.service` name: several instances provide the block contract (one
// per stick), so consumers are routed here by the device manager's
// lineage, never by a registry bind — the endpoint goes up in the hello.
service.run(block_protocol.message_maximum, .{
.service = "block",
.init = initialise,
.on_message = onMessage,
});
@@ -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 {
for (&drivers) |*driver| {
if (driver.used and driver.device_id == device_id) return true;
return driverEntryForDevice(device_id) != null;
}
return false;
/// 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 driver;
}
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;
}
+4 -2
View File
@@ -10,8 +10,10 @@ pub fn build(b: *std.Build) void {
.name = "fat",
.root_source_file = b.path("fat.zig"),
.imports = &.{
"block", "envelope", "file-system", "ipc", "logging", "memory", "process",
"service", "time", "vfs-protocol",
"block", "channel", "device-manager-protocol", "driver",
"envelope", "file-system", "ipc", "logging",
"memory", "process", "service", "time",
"vfs-protocol",
},
});
b.installArtifact(exe);
+54 -1
View File
@@ -10,6 +10,9 @@
//! it.
const std = @import("std");
const channel = @import("channel");
const device_manager_protocol = @import("device-manager-protocol");
const driver = @import("driver");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -116,6 +119,56 @@ const mount_retry_ms = 500;
var mounted = false;
var service_endpoint: ipc.Handle = 0;
/// The one channel to the device manager, opened on first need and kept — the
/// poll retries on it, never spending a handle-table slot per attempt.
var manager_handle: ?ipc.Handle = null;
/// Find the volume's provider through the device manager (establishment by
/// lineage, communication.md "Establishment: two planes" — `block` is not a
/// registry name; one storage process serves each stick): enumerate the
/// manager's tree, take the FIRST usb mass-storage child by enumeration order
/// (deterministic within a boot; single-volume by construction, and choosing
/// the BOOT volume by content when two sticks are present is the M21 remount
/// track), and consumer-hello for the channel of the driver bound to it.
/// Null until the chain is up — the caller's poll retries.
fn acquireVolume() ?block.Device {
const manager = manager_handle orelse opened: {
const handle = channel.openEndpoint("device-manager") orelse return null;
manager_handle = handle;
break :opened handle;
};
// The envelope's reserved `enumerate` verb, PAGED: one reply carries only
// a handful of entries and a real tree (a dozen ACPI nodes before the
// first USB child) is bigger, so `Header.target` is the start cursor and
// a short page is the end. Identity is the bus's native triple, for USB
// (base << 16) | (class << 8) | protocol — mass storage is base 0x08,
// subclass 0x06 (SCSI transparent), the same key devices.csv matches on.
const Entry = device_manager_protocol.ChildEntry;
var start: u64 = 0;
while (true) {
const enumerate = envelope.Header{ .operation = envelope.operation_enumerate, .target = start };
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
const length = ipc.call(manager, std.mem.asBytes(&enumerate), &reply) catch return null;
const status = envelope.statusOf(reply[0..length]) orelse return null;
if (status.status != 0) return null;
const carried = @min(@as(usize, status.len), length -| envelope.prefix_size);
const tail = reply[envelope.prefix_size..][0..carried];
const count = tail.len / @sizeOf(Entry);
if (count == 0) return null; // the tree is exhausted; no volume yet
var index: usize = 0;
while (index < count) : (index += 1) {
const entry = std.mem.bytesToValue(Entry, tail[index * @sizeOf(Entry) ..][0..@sizeOf(Entry)]);
if (entry.device_id == device_manager_protocol.no_device) continue;
if ((entry.identity >> 16) & 0xff != 0x08 or (entry.identity >> 8) & 0xff != 0x06) continue;
const exchanged = driver.helloOn(manager, .consumer, entry.device_id, null, true) orelse return null;
const provider = exchanged.channel orelse continue; // its driver not up yet — next tick
return .{ .endpoint = provider };
}
start += count;
}
}
fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint;
@@ -133,7 +186,7 @@ fn initialise(endpoint: ipc.Handle) bool {
/// `mounted` on success; a failure leaves everything untouched for the next tick.
fn tryBringUp() void {
if (mounted) return;
const device = block.tryOpen() orelse return;
const device = acquireVolume() orelse return;
const geometry = device.geometry() orelse {
_ = logging.write("/system/services/fat: block geometry unavailable\n");
return;