diff --git a/docs/device-manager.md b/docs/device-manager.md index e38eea0..a93f6b3 100644 --- a/docs/device-manager.md +++ b/docs/device-manager.md @@ -4,8 +4,11 @@ with its deadline, supervised spawn, restart with backoff, and the crash-loop cap are in — usb-xhci-bus is the first conforming driver, and the `driver-restart` scenario proves fault → backoff → re-claim → cap end to end. -Tree reports (M18.2) and the application surface (M18.3) remain design. The -primitives underneath are real ([process-management.md](process-management.md): +Tree reports are built too (M18.2, 2026-07-13): the xHCI driver scans its +root-hub ports and reports each connected device (`child_added`); the manager +mirrors them and prunes a dead reporter's children, and the `usb-report` +scenario proves report → prune → respawn → re-report. The application surface +(M18.3) remains design. The primitives underneath are real ([process-management.md](process-management.md): spawn/supervise/kill/exit-notification; [driver-model.md](driver-model.md): the device table as a capability system; [drivers.md](drivers.md): claim/map/IRQ), and the first per-device driver spawn works (the device manager matches the xHCI controller by PCI diff --git a/docs/m17-m18-plan.md b/docs/m17-m18-plan.md index 9cd0c22..3b35e92 100644 --- a/docs/m17-m18-plan.md +++ b/docs/m17-m18-plan.md @@ -53,7 +53,11 @@ only when its definition of green holds. re-proving claim release each respawn; `driver-restart` scenario; maximum_tasks 16→32 — the sweep was overflowing the pool; suite 52/52) - [x] **merge** `feat/device-manager` → main, push (merged 2026-07-13) -- [ ] **M18.2** — xHCI port scan + tree reports (branch `feat/usb-xhci-bus`) +- [x] **M18.2** — xHCI port scan + tree reports (child_added/child_removed in + the protocol; the manager's child mirror with death-pruning; xHCI maps the + register BAR — resource 0 is ECAM — reads CAPLENGTH/HCSPARAMS1, scans + PORTSC, reports connected ports with speed-class identity; `usb-report` + scenario proves report → prune → respawn → re-report; suite 53/53) - [ ] **M18.3** — app surface: enumerate/subscribe + device-list - [ ] **merge** `feat/usb-xhci-bus` → main, push — **loop ends here** diff --git a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig index cb23480..7f3048f 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig @@ -4,11 +4,14 @@ //! argv[1]; this instance claims that device and no other, so multiple //! instances never fight over hardware. //! -//! M18.1 (this increment): a harness service and the first conforming driver of -//! the device-manager protocol — claim the controller, `hello` the manager -//! (role, version, assignment) inside its deadline, then serve. Controller -//! bring-up (map the MMIO window, reset, port scan) and tree reports -//! (`child_added` for each connected port) land in M18.2. +//! M18.2 (this increment): after the hello, real hardware — map the xHC's +//! register window (the first memory BAR; resource 0 is the ECAM config +//! space), read the capability registers, and walk the root-hub ports: one +//! `child_added` report to the manager per connected port, carrying the port +//! number and the PORTSC speed class as identity. No transfer rings yet — +//! descriptors and USB class matching are the USB track; the connect bit and +//! speed come straight from PORTSC, which reflects hardware state whether or +//! not the controller is running. const std = @import("std"); const runtime = @import("runtime"); @@ -47,12 +50,16 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { return false; }; - // The controller's operational registers live behind BAR0, enumerated as - // the device's first memory resource. - const register_window = for (descriptor.resources[0..@intCast(descriptor.resource_count)]) |resource| { - if (resource.kind == @intFromEnum(device.ResourceKind.memory)) break resource; + // The xHC's registers live behind the first memory BAR. Resource 0 is the + // function's ECAM configuration space (M15), so the walk starts at 1. + var register_index: u64 = 0; + const register_window = for (descriptor.resources[1..@intCast(descriptor.resource_count)], 1..) |resource, index| { + if (resource.kind == @intFromEnum(device.ResourceKind.memory)) { + register_index = index; + break resource; + } } else { - writeLine("usb-xhci-bus: controller device {d} has no MMIO window\n", .{controller_id}); + writeLine("usb-xhci-bus: controller device {d} has no register BAR\n", .{controller_id}); return false; }; writeLine("usb-xhci-bus: claimed controller device {d} (registers at 0x{x}, {d} bytes)\n", .{ @@ -60,6 +67,10 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { register_window.start, register_window.len, }); + register_base = device.mmioMap(controller_id, register_index) orelse { + _ = runtime.system.write("usb-xhci-bus: mmio_map failed\n"); + return false; + }; // The handshake: role, protocol version, assignment — inside the manager's // deadline (the lookup retries cover the manager still registering). @@ -84,9 +95,56 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { return false; } _ = runtime.system.write("usb-xhci-bus: hello acknowledged\n"); + + scanPorts(h); return true; } +var register_base: usize = 0; + +/// One 32-bit volatile register read at `offset` from the mapped window. +fn readRegister(offset: usize) u32 { + const register: *volatile u32 = @ptrFromInt(register_base + offset); + return register.*; +} + +/// The root-hub port scan: read the capability registers for the port count +/// and the operational-register offset, then one PORTSC per port. The connect +/// bit (CCS) and the speed field reflect hardware state directly — no +/// controller reset or run needed to *see* the devices; driving them needs the +/// rings (the USB track). +fn scanPorts(manager: runtime.ipc.Handle) void { + // Capability registers: CAPLENGTH is byte 0 of the first dword; HCSPARAMS1 + // carries MaxPorts in bits 31:24. + const capability_length = readRegister(0) & 0xFF; + const structural = readRegister(0x04); + const maximum_ports: u32 = structural >> 24; + writeLine("usb-xhci-bus: {d} root-hub ports\n", .{maximum_ports}); + + // PORTSC registers: operational base + 0x400 + 0x10 per port (1-based). + var port: u32 = 1; + var connected: u32 = 0; + while (port <= maximum_ports) : (port += 1) { + const port_status = readRegister(capability_length + 0x400 + 0x10 * (port - 1)); + if (port_status & 1 == 0) continue; // CCS: nothing connected + connected += 1; + const speed = (port_status >> 10) & 0xF; // the PORTSC port-speed class + writeLine("usb-xhci-bus: port {d} connected (speed class {d})\n", .{ port, speed }); + + const report = protocol.ChildAdded{ + .parent = controller_id, + .bus_address = port, + .identity = speed, + }; + var reply: [protocol.message_maximum]u8 = undefined; + _ = runtime.ipc.call(manager, std.mem.asBytes(&report), &reply) catch { + writeLine("usb-xhci-bus: child report for port {d} failed\n", .{port}); + continue; + }; + } + if (connected == 0) _ = runtime.system.write("usb-xhci-bus: no devices connected\n"); +} + /// No bus protocol to serve yet — transfer requests arrive with the USB track. fn onMessage(message: []const u8, reply: []u8, sender: u32) usize { _ = message; diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 44cd12b..8310fd1 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -140,6 +140,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { signalsTest(boot_information); } else if (eql(case, "driver-restart")) { driverRestartTest(boot_information); + } else if (eql(case, "usb-report")) { + usbReportTest(boot_information); } else if (eql(case, "initial-ramdisk")) { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { @@ -1694,6 +1696,40 @@ fn driverRestartTest(boot_information: *const BootInformation) void { result(); } +/// M18.2: bus tree reports, end to end. The manager (test-usb-restart mode) +/// spawns the xHCI driver; the driver maps its BAR, scans the root-hub ports, +/// and reports the two QEMU devices; the manager mirrors them, kills the +/// reporter (the test trigger), prunes both children, restarts the driver with +/// backoff, and the respawned instance re-claims, re-scans, and re-reports. +/// The harness's ordered expect regex is the assertion; this test only +/// orchestrates the spawn. +fn usbReportTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: usb-report\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over an initial_ramdisk", false); + result(); + return; + } + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(image) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + + process.setInitialRamdisk(image); + var manager: u32 = 0; + var i: u32 = 0; + while (i < rd.count) : (i += 1) { + const item = rd.entry(i) orelse continue; + if (!eql(item.name, "device-manager")) continue; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0; + break; + } + check("device-manager spawned in test-usb-restart mode", manager != 0); + result(); +} + /// The whole user-side surface at once: spawn process-test's supervisor role, /// which — entirely from ring 3 — creates an exit endpoint, spawns its two /// children supervised, sees them in process_enumerate, kills them (one blocked, diff --git a/system/services/device-manager/device-manager-protocol.zig b/system/services/device-manager/device-manager-protocol.zig index d53a50f..f359d3b 100644 --- a/system/services/device-manager/device-manager-protocol.zig +++ b/system/services/device-manager/device-manager-protocol.zig @@ -19,10 +19,11 @@ pub const Role = enum(u8) { device = 2, }; -/// The message kinds. `child_added`/`child_removed` land in M18.2; -/// `enumerate`/`subscribe` in M18.3. +/// The message kinds. `enumerate`/`subscribe` land in M18.3. pub const Operation = enum(u8) { hello = 1, + child_added = 2, + child_removed = 3, }; /// `Hello.device_id` for a driver that serves no enumerated device (a test @@ -54,5 +55,47 @@ pub const HelloReply = extern struct { pub const reply_size = @sizeOf(HelloReply); +/// A bus driver reporting one device it discovered behind its controller +/// (docs/device-manager.md "the tree"). Identity is the bus's native language — +/// for USB a port-speed class; the (class, subclass, protocol) triple joins it +/// once control transfers exist (the USB track). The manager mirrors the child +/// into its tree; when the reporting driver dies, the manager prunes everything +/// it reported (the children describe protocol state that died with it) and the +/// restarted instance rediscovers and re-reports. +pub const ChildAdded = extern struct { + operation: u8 = @intFromEnum(Operation.child_added), + reserved0: u8 = 0, + reserved1: u16 = 0, + reserved2: u32 = 0, + /// The reporting driver's own device (the controller) — the child's parent. + parent: u64, + /// Where on the bus (for USB: the root port number, 1-based). + bus_address: u64, + /// Bus-specific identity (for USB: the PORTSC port-speed class). + identity: u64, +}; + +pub const child_added_size = @sizeOf(ChildAdded); + +/// A bus driver reporting a device gone (hot-unplug). Not yet sent by any +/// driver — the port scan has no unplug interrupt — but the manager handles it; +/// death-pruning covers removal until hotplug lands. +pub const ChildRemoved = extern struct { + operation: u8 = @intFromEnum(Operation.child_removed), + reserved0: u8 = 0, + reserved1: u16 = 0, + reserved2: u32 = 0, + parent: u64, + bus_address: u64, +}; + +pub const child_removed_size = @sizeOf(ChildRemoved); + +/// The manager's answer to a tree report. +pub const ReportReply = extern struct { + status: i32, + reserved: u32 = 0, +}; + /// Upper bound on any message in this protocol — sizes the endpoint buffers. pub const message_maximum = 64; diff --git a/system/services/device-manager/device-manager.zig b/system/services/device-manager/device-manager.zig index 63c0ff2..c3dece0 100644 --- a/system/services/device-manager/device-manager.zig +++ b/system/services/device-manager/device-manager.zig @@ -106,6 +106,62 @@ const maximum_drivers = 16; var drivers: [maximum_drivers]Driver = .{Driver{}} ** maximum_drivers; var manager_endpoint: runtime.ipc.Handle = 0; var test_restart_mode = false; +var test_usb_restart_mode = false; +var test_usb_killed = false; + +/// The manager's mirror of what bus drivers report (docs/device-manager.md "the +/// tree"): the children, keyed by (parent, bus address), each remembering which +/// driver instance reported it — that is what death-pruning sweeps by. +const Child = struct { + used: bool = false, + parent: u64 = 0, + bus_address: u64 = 0, + identity: u64 = 0, + reporter: u32 = 0, // the reporting driver instance's process id +}; + +const maximum_children = 32; +var children: [maximum_children]Child = .{Child{}} ** maximum_children; + +/// Record (or refresh) a reported child. Refreshing matters: a restarted bus +/// driver re-reports what it rediscovers, and the same (parent, port) must not +/// duplicate. +fn addChild(parent: u64, bus_address: u64, identity: u64, reporter: u32) bool { + var free: ?*Child = null; + for (&children) |*child| { + if (child.used and child.parent == parent and child.bus_address == bus_address) { + child.identity = identity; + child.reporter = reporter; + return true; + } + if (!child.used and free == null) free = child; + } + const slot = free orelse return false; + slot.* = .{ .used = true, .parent = parent, .bus_address = bus_address, .identity = identity, .reporter = reporter }; + return true; +} + +/// Prune every child a dead driver instance reported: the children describe +/// protocol state (slots, rings) that died with the process — keeping the nodes +/// would be keeping a lie. The restarted instance rediscovers and re-reports. +fn pruneChildrenOf(reporter: u32) void { + for (&children) |*child| { + if (child.used and child.reporter == reporter) { + writeLine("device-manager: child removed (device {d} port {d})\n", .{ child.parent, child.bus_address }); + child.used = false; + } + } +} + +/// How many children a driver instance has reported (the test-usb-restart +/// trigger counts these). +fn childCountOf(reporter: u32) u32 { + var n: u32 = 0; + for (&children) |*child| { + if (child.used and child.reporter == reporter) n += 1; + } + return n; +} fn driverByProcess(process_id: u32) ?*Driver { for (&drivers) |*driver| { @@ -171,9 +227,11 @@ fn spawnDriver(driver: *Driver) void { } } -/// A driver died. The exit reason (M17.2) is the whole decision: a clean exit -/// meant to stop; anything else restarts with backoff until the crash-loop cap. +/// A driver died. Prune what it reported first — then the exit reason (M17.2) +/// is the whole restart decision: a clean exit meant to stop; anything else +/// restarts with backoff until the crash-loop cap. fn onDriverExit(driver: *Driver) void { + pruneChildrenOf(driver.process_id); const reason = runtime.process.exitReason(driver.process_id) orelse .fault; if (reason == .exited) { driver.state = .stopped; @@ -261,9 +319,15 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { } fn onMessage(message: []const u8, reply: []u8, sender: u32) usize { + if (message.len < 1) return 0; + switch (message[0]) { + @intFromEnum(protocol.Operation.child_added) => return onChildAdded(message, reply, sender), + @intFromEnum(protocol.Operation.child_removed) => return onChildRemoved(message, reply, sender), + @intFromEnum(protocol.Operation.hello) => {}, + else => return 0, + } if (message.len < protocol.hello_size) return 0; const hello = std.mem.bytesToValue(protocol.Hello, message[0..protocol.hello_size]); - if (hello.operation != @intFromEnum(protocol.Operation.hello)) return 0; var status: i32 = 0; if (hello.version != protocol.version) { @@ -281,6 +345,47 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32) usize { return protocol.reply_size; } +/// A bus driver reported a discovered device: mirror it, and in +/// test-usb-restart mode kill the reporter once after its second child — the +/// deterministic trigger for prune -> backoff -> respawn -> re-report. +fn onChildAdded(message: []const u8, reply: []u8, sender: u32) usize { + if (message.len < protocol.child_added_size) return 0; + const report = std.mem.bytesToValue(protocol.ChildAdded, message[0..protocol.child_added_size]); + var status: i32 = 0; + if (driverByProcess(sender)) |driver| { + if (!addChild(report.parent, report.bus_address, report.identity, sender)) status = -1; + writeLine("device-manager: child added (device {d} port {d}, identity {d}) by {s}\n", .{ report.parent, report.bus_address, report.identity, driver.name() }); + } else { + status = -1; + } + const report_reply = protocol.ReportReply{ .status = status }; + @memcpy(reply[0..@sizeOf(protocol.ReportReply)], std.mem.asBytes(&report_reply)); + if (test_usb_restart_mode and !test_usb_killed and childCountOf(sender) >= 2) { + test_usb_killed = true; + writeLine("device-manager: test mode: killing the reporter\n", .{}); + _ = system.kill(sender); + } + return @sizeOf(protocol.ReportReply); +} + +/// A bus driver reported a device gone (hot-unplug; no sender exists yet, but +/// the handler is protocol-complete — death-pruning covers removal until then). +fn onChildRemoved(message: []const u8, reply: []u8, sender: u32) usize { + if (message.len < protocol.child_removed_size) return 0; + const report = std.mem.bytesToValue(protocol.ChildRemoved, message[0..protocol.child_removed_size]); + var status: i32 = -1; + for (&children) |*child| { + if (child.used and child.parent == report.parent and child.bus_address == report.bus_address and child.reporter == sender) { + writeLine("device-manager: child removed (device {d} port {d})\n", .{ child.parent, child.bus_address }); + child.used = false; + status = 0; + } + } + const report_reply = protocol.ReportReply{ .status = status }; + @memcpy(reply[0..@sizeOf(protocol.ReportReply)], std.mem.asBytes(&report_reply)); + return @sizeOf(protocol.ReportReply); +} + fn onNotification(badge: u64) void { if (badge & runtime.ipc.notify_exit_bit != 0) { const dead: u32 = @intCast(badge & ~(runtime.ipc.notify_badge_bit | runtime.ipc.notify_exit_bit)); @@ -293,6 +398,7 @@ fn onNotification(badge: u64) void { pub fn main(init: runtime.process.Init) void { if (init.arguments.get(1)) |mode| { test_restart_mode = std.mem.eql(u8, mode, "test-restart"); + test_usb_restart_mode = std.mem.eql(u8, mode, "test-usb-restart"); } runtime.service.run(protocol.message_maximum, .{ .service = .device_manager, diff --git a/test/qemu_test.py b/test/qemu_test.py index e9eec0c..9e212dc 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -258,6 +258,22 @@ CASES = [ "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # M18.2: bus tree reports — the xHCI driver scans its root-hub ports and + # reports both QEMU devices; the manager mirrors, prunes on the reporter's + # death, and the respawned driver re-reports (docs/device-manager.md). + {"name": "usb-report", + "smp": 4, + "timeout": 90, + "qemu_extra": ["-device", "qemu-xhci,id=xhci", + "-device", "usb-kbd,bus=xhci.0", + "-device", "usb-mouse,bus=xhci.0"], + "expect": r"device-manager: child added[\s\S]*" + r"device-manager: child added[\s\S]*" + r"device-manager: test mode: killing the reporter[\s\S]*" + r"device-manager: child removed[\s\S]*" + r"device-manager: restarting usb-xhci-bus[\s\S]*" + r"device-manager: child added", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # M18.1: the device manager's hello + restart policy — xHCI hellos clean and # stays; crash-test faults, is restarted with backoff (re-claiming its device # each time), and hits the crash-loop cap (docs/device-manager.md).