From 79d859a11178427278c614c0db6e6d52133680b0 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:28:29 +0100 Subject: [PATCH 1/2] The xHCI driver scans its root-hub ports and reports the tree (M18.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit child_added/child_removed join the device-manager protocol. The driver maps its register BAR (resource 0 is the ECAM config space; the walk starts at 1), reads CAPLENGTH and HCSPARAMS1, and reads one PORTSC per port: the connect bit and speed class come straight from hardware, no rings needed to see the devices. The manager mirrors reported children keyed by (parent, port), remembers which instance reported each, and prunes a dead reporter's children before deciding the restart — the children describe protocol state that died with the process. The usb-report scenario drives the whole loop: two QEMU devices reported, reporter killed, children pruned, driver respawned with backoff, and the new instance re-claims, re-scans, and re-reports. --- docs/device-manager.md | 7 +- docs/m17-m18-plan.md | 6 +- system/drivers/usb-xhci-bus/usb-xhci-bus.zig | 78 ++++++++++-- system/kernel/tests.zig | 36 ++++++ .../device-manager-protocol.zig | 47 +++++++- .../device-manager/device-manager.zig | 112 +++++++++++++++++- test/qemu_test.py | 16 +++ 7 files changed, 284 insertions(+), 18 deletions(-) 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). From d8778b4b70547c0fb74d5a0b4cdd4e16a89177e3 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:49:03 +0100 Subject: [PATCH 2/2] The application surface: enumerate, subscribe, and device-list (M18.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications ask the device manager for the tree (enumerate: a header plus ChildEntry records) and subscribe to published add/remove events by handing their endpoint over as the call's capability — the input-service pattern; events are the same ChildAdded/ChildRemoved structs the bus drivers send, one encoding in both directions. device-list is the first client: it prints the tree, subscribes, and narrates the events through a driver restart. The protocol's message maximum is capped at the kernel's IPC MESSAGE_MAXIMUM (256 bytes, ten entries per reply; paging joins the protocol when a tree outgrows one message). The startUserTask debug print is gone: it wrote to serial unserialized against user-space lines and sheared concurrent log markers in half — the root cause of the scenario flakes. --- build.zig | 3 + docs/device-manager.md | 6 +- docs/m17-m18-plan.md | 7 +- library/runtime/service.zig | 8 +- system/drivers/usb-xhci-bus/usb-xhci-bus.zig | 3 +- system/kernel/scheduler.zig | 5 +- system/kernel/tests.zig | 35 ++++++++ system/services/device-list/device-list.zig | 88 +++++++++++++++++++ .../device-manager-protocol.zig | 42 ++++++++- .../device-manager/device-manager.zig | 73 ++++++++++++++- system/services/process-test/process-test.zig | 3 +- system/services/vfs/vfs.zig | 3 +- test/qemu_test.py | 15 ++++ 13 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 system/services/device-list/device-list.zig diff --git a/build.zig b/build.zig index bae8552..4df79d0 100644 --- a/build.zig +++ b/build.zig @@ -342,6 +342,7 @@ pub fn build(b: *std.Build) void { // A test fixture, not a real driver: hellos to the device manager, then faults — // what the driver-restart scenario drives the crash-loop cap with. const crash_test_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "crash-test", "system/services/crash-test/crash-test.zig"); + const device_list_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-list", "system/services/device-list/device-list.zig"); const device_manager_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-manager", "system/services/device-manager/device-manager.zig"); // The input service and its exercisers: the fan-out server, a hardware-free synthetic // source, and a subscriber that doubles as the `input` test's oracle. See docs/input.md. @@ -375,6 +376,8 @@ pub fn build(b: *std.Build) void { mk_run.addFileArg(usb_xhci_bus_exe.getEmittedBin()); mk_run.addArg("crash-test"); mk_run.addFileArg(crash_test_exe.getEmittedBin()); + mk_run.addArg("device-list"); + mk_run.addFileArg(device_list_exe.getEmittedBin()); mk_run.addArg("device-manager"); mk_run.addFileArg(device_manager_exe.getEmittedBin()); mk_run.addArg("input"); diff --git a/docs/device-manager.md b/docs/device-manager.md index a93f6b3..b28d998 100644 --- a/docs/device-manager.md +++ b/docs/device-manager.md @@ -7,8 +7,10 @@ cap are in — usb-xhci-bus is the first conforming driver, and the 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): +scenario proves report → prune → respawn → re-report. The application surface is built (M18.3, 2026-07-13): +`enumerate` and `subscribe` over IPC, with `device-list` as the first client — +the manager is now the one answer to "what devices exist" for applications. +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 3b35e92..d3e2a72 100644 --- a/docs/m17-m18-plan.md +++ b/docs/m17-m18-plan.md @@ -58,7 +58,12 @@ only when its definition of green holds. 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 +- [x] **M18.3** — app surface: enumerate/subscribe over IPC (subscriber + endpoint rides as the call's capability; events are the same structs the + buses send); device-list first client; protocol capped at the kernel's + IPC MESSAGE_MAXIMUM (256); the startUserTask debug print removed — it + sheared concurrent serial lines and was the scenario-flake root cause; + `device-list` scenario; suite 54/54) - [ ] **merge** `feat/usb-xhci-bus` → main, push — **loop ends here** --- diff --git a/library/runtime/service.zig b/library/runtime/service.zig index f5c7064..8926754 100644 --- a/library/runtime/service.zig +++ b/library/runtime/service.zig @@ -22,8 +22,10 @@ pub const Callbacks = struct { /// Return false to abort startup (the process exits). init: ?*const fn (endpoint: ipc.Handle) bool = null, /// One protocol request from `sender` (a task id): write the reply into - /// `reply`, return its length. The zero-length ping never reaches this. - on_message: *const fn (message: []const u8, reply: []u8, sender: u32) usize, + /// `reply`, return its length. `capability` is the handle the request + /// carried, if any (M13 cap passing — how a subscriber hands over its + /// endpoint). The zero-length ping never reaches this. + on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize, /// A notification that is not a signal — a subscribed exit event, a bound /// IRQ, a timer landing. The raw badge; decode with the ipc helpers. on_notification: ?*const fn (badge: u64) void = null, @@ -76,6 +78,6 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void { reply_len = 0; // the universal ping: a zero-length reply, from the harness continue; } - reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId()); + reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), got.cap); } } diff --git a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig index 7f3048f..ee7898a 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig @@ -146,10 +146,11 @@ fn scanPorts(manager: runtime.ipc.Handle) void { } /// No bus protocol to serve yet — transfer requests arrive with the USB track. -fn onMessage(message: []const u8, reply: []u8, sender: u32) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize { _ = message; _ = reply; _ = sender; + _ = capability; return 0; } diff --git a/system/kernel/scheduler.zig b/system/kernel/scheduler.zig index c468a63..2f26023 100644 --- a/system/kernel/scheduler.zig +++ b/system/kernel/scheduler.zig @@ -350,8 +350,9 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority /// context switch and lock release. fn startUserTask() void { const t = current(); - var buffer: [96]u8 = undefined; - architecture.serialWrite(std.fmt.bufPrint(&buffer, "DBG startUserTask ip=0x{x} sp=0x{x} aspace=0x{x} kstack=0x{x}\n", .{ t.user_ip, t.user_sp, t.aspace, t.kstack_top }) catch ""); + // No serial chatter here: this runs on every spawn, unserialized against + // user-space writes, and its output used to shear concurrent log lines in + // half — the largest source of corrupted markers in the QEMU scenarios. architecture.jumpToUser(t.user_ip, t.user_sp); // noreturn } diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 8310fd1..bf6237b 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -142,6 +142,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { driverRestartTest(boot_information); } else if (eql(case, "usb-report")) { usbReportTest(boot_information); + } else if (eql(case, "device-list")) { + deviceListTest(boot_information); } else if (eql(case, "initial-ramdisk")) { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { @@ -1730,6 +1732,39 @@ fn usbReportTest(boot_information: *const BootInformation) void { result(); } +/// M18.3: the application surface. device-list enumerates the manager's tree +/// over IPC, subscribes with its endpoint as a capability, and prints every +/// published event; the manager's delayed test-kill of the reporter produces a +/// removed/added storm the subscriber must observe. The harness's ordered +/// expect regex is the assertion. +fn deviceListTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: device-list\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); + check("device-list spawned", spawnNamed(rd, "device-list")); + 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-list/device-list.zig b/system/services/device-list/device-list.zig new file mode 100644 index 0000000..4b72285 --- /dev/null +++ b/system/services/device-list/device-list.zig @@ -0,0 +1,88 @@ +//! device-list — the `ps` analog for the device tree (docs/device-manager.md +//! M18.3): asks the device manager for the tree over IPC, prints it, then +//! subscribes and prints every published add/remove event. The manager is the +//! one answer to "what devices exist" for user space; nothing here touches a +//! device_* system call. + +const std = @import("std"); +const runtime = @import("runtime"); +const protocol = runtime.device_manager_protocol; + +fn writeLine(comptime fmt: []const u8, arguments: anytype) void { + var line: [96]u8 = undefined; + _ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return); +} + +pub fn main() void { + var manager: ?runtime.ipc.Handle = null; + var tries: u32 = 0; + while (manager == null and tries < 200) : (tries += 1) { + manager = runtime.ipc.lookup(.device_manager); + if (manager == null) runtime.system.sleep(20); + } + const h = manager orelse { + _ = runtime.system.write("device-list: no device manager\n"); + return; + }; + + // The snapshot — polled briefly, because at boot the bus drivers may still + // be scanning: an empty first answer usually just means "too early". + var reply: [protocol.message_maximum]u8 = undefined; + var count: u32 = 0; + var length: usize = 0; + tries = 0; + while (tries < 20) : (tries += 1) { + const request = protocol.Enumerate{}; + length = runtime.ipc.call(h, std.mem.asBytes(&request), &reply) catch 0; + if (length >= @sizeOf(protocol.EnumerateReply)) { + count = std.mem.bytesToValue(protocol.EnumerateReply, reply[0..@sizeOf(protocol.EnumerateReply)]).count; + if (count != 0) break; + } + runtime.system.sleep(100); + } + writeLine("device-list: {d} devices\n", .{count}); + var offset: usize = @sizeOf(protocol.EnumerateReply); + var index: u32 = 0; + while (index < count and offset + @sizeOf(protocol.ChildEntry) <= length) : (index += 1) { + const entry = std.mem.bytesToValue(protocol.ChildEntry, reply[offset..][0..@sizeOf(protocol.ChildEntry)]); + writeLine("device-list: device {d} port {d} identity {d}\n", .{ entry.parent, entry.bus_address, entry.identity }); + offset += @sizeOf(protocol.ChildEntry); + } + + // The subscription: our endpoint rides as the call's capability; events + // arrive as buffered messages carrying the same structs the bus sends. + const endpoint = runtime.ipc.createIpcEndpoint() orelse { + _ = runtime.system.write("device-list: no endpoint\n"); + return; + }; + const subscribe = protocol.Subscribe{}; + _ = runtime.ipc.callCap(h, std.mem.asBytes(&subscribe), &reply, endpoint) catch { + _ = runtime.system.write("device-list: subscribe failed\n"); + return; + }; + _ = runtime.system.write("device-list: subscribed\n"); + + var receive: [protocol.message_maximum]u8 = undefined; + while (true) { + const got = runtime.ipc.replyWait(endpoint, &.{}, &receive, null); + if (!got.isMessage() or got.len < 1) continue; + switch (receive[0]) { + @intFromEnum(protocol.Operation.child_added) => { + if (got.len < protocol.child_added_size) continue; + const event = std.mem.bytesToValue(protocol.ChildAdded, receive[0..protocol.child_added_size]); + writeLine("device-list: added (device {d} port {d})\n", .{ event.parent, event.bus_address }); + }, + @intFromEnum(protocol.Operation.child_removed) => { + if (got.len < protocol.child_removed_size) continue; + const event = std.mem.bytesToValue(protocol.ChildRemoved, receive[0..protocol.child_removed_size]); + writeLine("device-list: removed (device {d} port {d})\n", .{ event.parent, event.bus_address }); + }, + else => {}, + } + } +} + +pub const panic = runtime.panic; +comptime { + _ = &runtime.start._start; // pull the runtime entry shim into the image +} diff --git a/system/services/device-manager/device-manager-protocol.zig b/system/services/device-manager/device-manager-protocol.zig index f359d3b..2a49db4 100644 --- a/system/services/device-manager/device-manager-protocol.zig +++ b/system/services/device-manager/device-manager-protocol.zig @@ -19,11 +19,13 @@ pub const Role = enum(u8) { device = 2, }; -/// The message kinds. `enumerate`/`subscribe` land in M18.3. +/// The message kinds. pub const Operation = enum(u8) { hello = 1, child_added = 2, child_removed = 3, + enumerate = 4, + subscribe = 5, }; /// `Hello.device_id` for a driver that serves no enumerated device (a test @@ -97,5 +99,41 @@ pub const ReportReply = extern struct { reserved: u32 = 0, }; +/// An application asking for the tree (M18.3): the reply is an EnumerateReply +/// header followed by `count` ChildEntry records. +pub const Enumerate = extern struct { + operation: u8 = @intFromEnum(Operation.enumerate), + reserved0: u8 = 0, + reserved1: u16 = 0, + reserved2: u32 = 0, +}; + +pub const EnumerateReply = extern struct { + status: i32, + /// ChildEntry records following this header. + count: u32, +}; + +pub const ChildEntry = extern struct { + parent: u64, + bus_address: u64, + identity: u64, +}; + +/// An application subscribing to published add/remove events (the input-service +/// pattern): the subscriber's endpoint rides as the call's **capability**, and +/// events arrive on it as buffered messages whose payload is the same +/// ChildAdded / ChildRemoved struct the bus drivers send — one encoding, both +/// directions. +pub const Subscribe = extern struct { + operation: u8 = @intFromEnum(Operation.subscribe), + reserved0: u8 = 0, + reserved1: u16 = 0, + reserved2: u32 = 0, +}; + /// Upper bound on any message in this protocol — sizes the endpoint buffers. -pub const message_maximum = 64; +/// Capped by the kernel's IPC MESSAGE_MAXIMUM (256): an EnumerateReply carries +/// up to ten ChildEntry records per call, plenty for the mirror's current +/// bounds; paging joins the protocol if a tree ever outgrows one message. +pub const message_maximum = 256; diff --git a/system/services/device-manager/device-manager.zig b/system/services/device-manager/device-manager.zig index c3dece0..b59f2a1 100644 --- a/system/services/device-manager/device-manager.zig +++ b/system/services/device-manager/device-manager.zig @@ -108,6 +108,25 @@ var manager_endpoint: runtime.ipc.Handle = 0; var test_restart_mode = false; var test_usb_restart_mode = false; var test_usb_killed = false; +var test_kill_pid: u32 = 0; +var test_kill_due_ns: u64 = 0; + +/// The application subscribers (M18.3, the input-service pattern): endpoints +/// handed over as capabilities, each receiving every child add/remove as a +/// buffered message. A subscriber whose endpoint stops accepting (it died) is +/// dropped on the failed send. +const maximum_subscribers = 8; +var subscribers: [maximum_subscribers]?runtime.ipc.Handle = .{null} ** maximum_subscribers; + +/// Publish one event (a ChildAdded or ChildRemoved struct, the same encoding +/// the bus drivers send) to every subscriber. +fn publishEvent(event: []const u8) void { + for (&subscribers) |*slot| { + if (slot.*) |handle| { + if (!runtime.ipc.send(handle, event)) slot.* = null; // dead subscriber + } + } +} /// 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 @@ -144,11 +163,14 @@ fn addChild(parent: u64, bus_address: u64, identity: u64, reporter: u32) bool { /// 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. +/// Watchers hear the honest story: removed now, added again on rediscovery. 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; + const event = protocol.ChildRemoved{ .parent = child.parent, .bus_address = child.bus_address }; + publishEvent(std.mem.asBytes(&event)); } } } @@ -259,6 +281,11 @@ fn onDriverExit(driver: *Driver) void { /// sweep serves every armed deadline. fn sweepDeadlines() void { const now = system.clock(); + if (test_kill_pid != 0 and now >= test_kill_due_ns) { + writeLine("device-manager: test mode: killing the reporter\n", .{}); + _ = system.kill(test_kill_pid); + test_kill_pid = 0; + } for (&drivers) |*driver| { if (!driver.used) continue; switch (driver.state) { @@ -318,11 +345,13 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { return true; } -fn onMessage(message: []const u8, reply: []u8, sender: u32) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime.ipc.Handle) 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.enumerate) => return onEnumerate(reply), + @intFromEnum(protocol.Operation.subscribe) => return onSubscribe(reply, capability), @intFromEnum(protocol.Operation.hello) => {}, else => return 0, } @@ -355,15 +384,19 @@ fn onChildAdded(message: []const u8, reply: []u8, sender: u32) usize { 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() }); + if (status == 0) publishEvent(message[0..protocol.child_added_size]); } 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) { + // Delayed, not immediate: the device-list scenario's subscriber needs a + // window to enumerate and subscribe before the events start. test_usb_killed = true; - writeLine("device-manager: test mode: killing the reporter\n", .{}); - _ = system.kill(sender); + test_kill_pid = sender; + test_kill_due_ns = system.clock() + 2_000_000_000; + _ = system.timerOnce(manager_endpoint, 2100); } return @sizeOf(protocol.ReportReply); } @@ -386,6 +419,40 @@ fn onChildRemoved(message: []const u8, reply: []u8, sender: u32) usize { return @sizeOf(protocol.ReportReply); } +/// An application asked for the tree: the mirror, as a header plus entries. +fn onEnumerate(reply: []u8) usize { + var count: u32 = 0; + var offset: usize = @sizeOf(protocol.EnumerateReply); + for (&children) |*child| { + if (!child.used) continue; + if (offset + @sizeOf(protocol.ChildEntry) > reply.len) break; + const entry = protocol.ChildEntry{ .parent = child.parent, .bus_address = child.bus_address, .identity = child.identity }; + @memcpy(reply[offset..][0..@sizeOf(protocol.ChildEntry)], std.mem.asBytes(&entry)); + offset += @sizeOf(protocol.ChildEntry); + count += 1; + } + const header = protocol.EnumerateReply{ .status = 0, .count = count }; + @memcpy(reply[0..@sizeOf(protocol.EnumerateReply)], std.mem.asBytes(&header)); + return offset; +} + +/// An application subscribed: its endpoint arrived as the call's capability. +fn onSubscribe(reply: []u8, capability: ?runtime.ipc.Handle) usize { + var status: i32 = -1; + if (capability) |handle| { + for (&subscribers) |*slot| { + if (slot.* == null) { + slot.* = handle; + status = 0; + break; + } + } + } + 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)); diff --git a/system/services/process-test/process-test.zig b/system/services/process-test/process-test.zig index 0cc2c50..0e5f353 100644 --- a/system/services/process-test/process-test.zig +++ b/system/services/process-test/process-test.zig @@ -51,8 +51,9 @@ fn awaitChildExit(endpoint: runtime.ipc.Handle) u32 { /// The harness-run child of the signals test: echoes requests, logs the two /// signals it handles. Terminate makes run() return, and returning from main is /// the clean exit the parent reads as ExitReason.exited. -fn echo(message: []const u8, reply: []u8, sender: u32) usize { +fn echo(message: []const u8, reply: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize { _ = sender; + _ = capability; const n = @min(message.len, reply.len); @memcpy(reply[0..n], message[0..n]); return n; diff --git a/system/services/vfs/vfs.zig b/system/services/vfs/vfs.zig index 9e17af8..25a528a 100644 --- a/system/services/vfs/vfs.zig +++ b/system/services/vfs/vfs.zig @@ -91,7 +91,8 @@ fn releaseClientHandles(client: u32) void { } /// Handle one request from `sender`; write the reply into `out`, return its length. -fn handle(message: []const u8, out: []u8, sender: u32) usize { +fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize { + _ = capability; if (message.len < protocol.request_size) return fail(out); const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]); const payload = message[protocol.request_size..]; diff --git a/test/qemu_test.py b/test/qemu_test.py index 9e212dc..79e168d 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -274,6 +274,21 @@ CASES = [ r"device-manager: restarting usb-xhci-bus[\s\S]*" r"device-manager: child added", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # M18.3: the application surface — device-list enumerates the tree over IPC, + # subscribes (endpoint as capability), and observes the removed/added events + # the reporter's test-kill produces (docs/device-manager.md). + {"name": "device-list", + "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-list: 2 devices[\s\S]*" + r"device-list: subscribed[\s\S]*" + r"device-manager: test mode: killing the reporter[\s\S]*" + r"device-list: removed \(device[\s\S]*" + r"device-list: added \(device", + "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).