From dfc7d6a609b30d2a2c32fe5a1ea5d935388d434c Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:22:53 +0100 Subject: [PATCH 1/5] The harness grows a QMP channel (M21.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every case now gets a -qmp unix socket (additive; no case notices). A minimal client does the capabilities handshake and executes one command; the per-case qmp_after hook sends it N seconds after boot, retrying until the guest's socket is up. A case with a hook configured cannot pass until the hook delivered — and the smoke case now carries a harmless query-status hook, so the channel is proven end to end on every run. This is how the power scenarios inject the real ACPI power-button event (system_powerdown) in M21.1 and M21.3. --- docs/m21-plan.md | 10 +++++----- test/qemu_test.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/m21-plan.md b/docs/m21-plan.md index 305e951..1c1629e 100644 --- a/docs/m21-plan.md +++ b/docs/m21-plan.md @@ -70,11 +70,11 @@ auto-merge to main when the branch is green; keep the branch; push everything. ## Status -- [ ] **M21.0** — baseline: rebase over anything newly merged (the dead-code - sweep touches acpi.zig); cut `feat/power-events`; add the QMP channel to - the harness (`-qmp unix:.../qmp.sock,server,nowait`, a small client with - the `qmp_capabilities` handshake, a per-case `qmp_after` hook that sends - a command N seconds after boot); existing suite stays green. +- [x] **M21.0** — baseline (dead-code sweep confirmed landed on main — no + acpi.zig conflict; `feat/power-events` cut; QMP channel in the harness: + always-on unix socket, client with the capabilities handshake, per-case + `qmp_after` hook, and a hook-must-deliver pass gate that the smoke case + now proves with a harmless query-status; suite 58/58). - [ ] **M21.1** — SCI + the power button: kernel appends the FADT as an acpi-tables memory resource; new `power-protocol` module + `ServiceId.power`; the acpi service converts to the harness, registers diff --git a/test/qemu_test.py b/test/qemu_test.py index 53d87b3..26bf613 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -18,9 +18,11 @@ Usage: """ import argparse +import json import os import re import shutil +import socket import subprocess import sys import time @@ -80,7 +82,10 @@ ARCHES = { # `expect`: a regex that must appear in serial output => pass. # `fail`: optional regex whose appearance => immediate fail. CASES = [ + # smoke also proves the QMP channel: the harmless query must be delivered + # (handshake + command) before the case may pass — see run_case. {"name": "smoke", + "qmp_after": {"delay": 2, "command": "query-status"}, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "discovery", @@ -418,6 +423,27 @@ def resolve_firmware(arch): + "\nInstall OVMF (edk2-ovmf / ovmf) or add its path above.") +def qmp_send(path, command): + """One QMP command: connect, capabilities handshake, execute. Raises on any + failure — the caller retries until the guest's socket is ready. This is how + a case injects a host-side event (system_powerdown = the ACPI power button) + into the running guest (docs/m21-plan.md).""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(5) + try: + sock.connect(path) + stream = sock.makefile("rw") + stream.readline() # the QMP greeting + stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n") + stream.flush() + stream.readline() # {"return": {}} + stream.write(json.dumps({"execute": command}) + "\n") + stream.flush() + stream.readline() + finally: + sock.close() + + def run_case(arch, case): err = build(arch, case["name"]) if err: @@ -439,12 +465,27 @@ def run_case(arch, case): cmd += ["-smp", str(case["smp"])] if case.get("qemu_extra"): # extra qemu args, e.g. -device intel-iommu for the IOMMU case cmd += case["qemu_extra"] + # A QMP control socket, always present (additive): how a case's `qmp_after` + # hook injects host-side events into the guest mid-run. + qmp_path = os.path.join(WORK, "qmp.sock") + if os.path.exists(qmp_path): + os.remove(qmp_path) + cmd += ["-qmp", f"unix:{qmp_path},server,nowait"] + qmp_after = case.get("qmp_after") # {"delay": seconds, "command": "..."} + qmp_sent = False + started = time.monotonic() qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: timeout = case.get("timeout", TIMEOUT) deadline = time.monotonic() + timeout while time.monotonic() < deadline: time.sleep(0.2) + if qmp_after and not qmp_sent and time.monotonic() - started >= qmp_after["delay"]: + try: + qmp_send(qmp_path, qmp_after["command"]) + qmp_sent = True + except OSError: + pass # socket not up yet; retry next tick text = "" if os.path.exists(serial): with open(serial, "r", errors="replace") as f: @@ -452,6 +493,8 @@ def run_case(arch, case): if fail and fail.search(text): return False, "hit failure marker" if expect.search(text): + if qmp_after and not qmp_sent: + continue # the hook must deliver before the case may pass return True, "matched " + repr(case["expect"]) if qemu.poll() is not None: # QEMU exited on its own if expect.search(text): From 1f2c60b3ec381c382ccf11e230a4d81cebf48047 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:38:03 +0100 Subject: [PATCH 2/5] The power button, in ring 3: SCI bound, fixed event published (M21.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel publishes the FADT as one more acpi-tables memory resource (tagged by its intact FACP header — the AML blobs are header-stripped); the acpi service reads the PM1 event/control and GPE register ports from that copy, so the kernel's own FADT parse is untouched. A power-protocol module (ServiceId.power = 5, domain-named so an ARM PSCI service can serve the same id) carries subscribe / shutdown / events. The acpi service converts to runtime.service.run — device discovery, the .power protocol, and the SCI notification all fold into one loop. At startup it enables ACPI mode if SCI_EN is clear (the SMI dance), binds the SCI (found as the node's len-1 irq resource, distinct from the broad window), and sets PWRBTN_EN. On the SCI it reads PM1_STS, clears PWRBTN_STS write-1, logs the press, publishes power_button to subscribers, and always acks. The power-button scenario proves it with a real QMP system_powerdown injected mid-run through the M21.0 channel. --- build.zig | 6 + docs/m21-plan.md | 17 +- library/runtime/runtime.zig | 3 + system/abi.zig | 1 + system/devices/acpi.zig | 14 ++ system/kernel/tests.zig | 2 + system/services/acpi/acpi.zig | 273 +++++++++++++++++++++++++---- system/services/power/protocol.zig | 68 +++++++ test/qemu_test.py | 10 ++ 9 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 system/services/power/protocol.zig diff --git a/build.zig b/build.zig index 34e5939..1f404f5 100644 --- a/build.zig +++ b/build.zig @@ -226,6 +226,12 @@ pub fn build(b: *std.Build) void { }); runtime_module.addImport("device-manager-protocol", device_manager_protocol_module); + // The power protocol: system power's domain-named surface (docs/m21-plan.md). + const power_protocol_module = b.addModule("power-protocol", .{ + .root_source_file = b.path("system/services/power/protocol.zig"), + }); + runtime_module.addImport("power-protocol", power_protocol_module); + // Typed volatile MMIO register access + memory-ordering barriers, for drivers on // top of an mmio_map grant. Depends only on `builtin` (arch-conditional barriers); // no target set, so it inherits each driver's. See library/mmio/mmio.zig. diff --git a/docs/m21-plan.md b/docs/m21-plan.md index 1c1629e..7ad0928 100644 --- a/docs/m21-plan.md +++ b/docs/m21-plan.md @@ -75,14 +75,15 @@ auto-merge to main when the branch is green; keep the branch; push everything. always-on unix socket, client with the capabilities handshake, per-case `qmp_after` hook, and a hook-must-deliver pass gate that the smoke case now proves with a harmless query-status; suite 58/58). -- [ ] **M21.1** — SCI + the power button: kernel appends the FADT as an - acpi-tables memory resource; new `power-protocol` module + - `ServiceId.power`; the acpi service converts to the harness, registers - `.power`, parses the event/GPE blocks from its FADT copy, enables ACPI - mode if needed (SMI dance, spin on SCI_EN), binds the SCI, sets - PWRBTN_EN; on SCI reads/clears PM1_STS and publishes `power_button` - (log: `power: button pressed`), always irqAck. Scenario `power-button`: - `qmp_after system_powerdown` → expect the log line. +- [x] **M21.1** — SCI + the power button (kernel appends the FADT as an + acpi-tables memory resource, tagged by its "FACP" header; `power-protocol` + module + `ServiceId.power = 5`; the acpi service converted to + `runtime.service.run`, registers `.power`, reads PM1 event/control + GPE + ports from its FADT copy, enables ACPI mode if SCI_EN is clear, binds the + SCI (the len-1 irq), sets PWRBTN_EN; the SCI handler clears PM1_STS, + logs `power: button pressed`, publishes `power_button`, acks. Scenario + `power-button` injects a real `system_powerdown` via QMP; initial-ramdisk + timeout 30→60s for the service's added boot work; suite 59/59). - [ ] **M21.2** — Notify + GPE dispatch: interpreter handles `notify_opcode` into a bounded queue drained after evaluate(); on GPE status bits the service evaluates `\_GPE._Lxx`/`_Exx`, maps notified nodes to events diff --git a/library/runtime/runtime.zig b/library/runtime/runtime.zig index 29b1895..efdc5a5 100644 --- a/library/runtime/runtime.zig +++ b/library/runtime/runtime.zig @@ -20,6 +20,9 @@ pub const vfs_protocol = @import("vfs-protocol"); /// The device-manager protocol: hello + tree reports (docs/device-manager.md). pub const device_manager_protocol = @import("device-manager-protocol"); + +/// The power protocol: events (button, lid, battery) + shutdown (docs/m21-plan.md). +pub const power_protocol = @import("power-protocol"); /// Keyboard-event listening (subscribe/next) and broadcasting (publish), over the input /// service. See library/runtime/input.zig and system/services/input/. pub const input = @import("input.zig"); diff --git a/system/abi.zig b/system/abi.zig index 6e6e985..657f4e4 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -177,6 +177,7 @@ pub const ServiceId = enum(u32) { input = 2, ps2_bus = 3, // the 8042 owner; child device drivers attach here for raw bytes device_manager = 4, // the tree, the matcher, the supervisor (docs/device-manager.md) + power = 5, // system power: events (button, lid, battery) + shutdown (docs/m21-plan.md; domain-named per decision 7 — the acpi service registers it on x86, a PSCI service will on ARM) _, }; diff --git a/system/devices/acpi.zig b/system/devices/acpi.zig index 58a028f..c1a4db0 100644 --- a/system/devices/acpi.zig +++ b/system/devices/acpi.zig @@ -157,6 +157,13 @@ pub var namespace: ?aml.Namespace = null; /// Physical address of the DSDT the FADT points at, or 0. pub var dsdt_physical: u64 = 0; +/// The FADT itself (physical + length), published on the acpi-tables node so +/// the ring-3 acpi service can read the PM1 event and GPE blocks it needs for +/// the event side (docs/m21-plan.md decision 3). Distinguished from the AML +/// blob resources by its intact "FACP" header — the blobs are header-stripped. +var fadt_physical: u64 = 0; +var fadt_length: u64 = 0; + // AML blocks (DSDT + any SSDTs) collected during the table walk, as physical // address + length of each table's post-header bytecode. Scanned after the walk // for the sleep-state (`_Sx`) packages. @@ -373,6 +380,8 @@ pub fn discover(rsdp_physical: u64, memory_regions: []const boot_handoff.MemoryR // Start clean so a re-run doesn't accumulate stale state. power_information = .{}; + fadt_physical = 0; + fadt_length = 0; platform_information = .{}; aml_stats = .{}; namespace = null; @@ -447,6 +456,9 @@ fn publishAcpiTablesNode(device_tree: *DeviceTree) !void { // SCI (recorded first, len 1) stays distinct so M21 can pick it out. if (power_information.sci_interrupt != 0) _ = node.addResource(.irq, power_information.sci_interrupt, 1); _ = node.addResource(.irq, 0, 256); + // The FADT rides along (M21): the service reads the PM1 event / GPE blocks + // from its own copy, telling it apart from the AML blobs by signature. + if (fadt_physical != 0) _ = node.addResource(.memory, fadt_physical, fadt_length); } /// The number of Device objects in the namespace built during discovery, or 0. @@ -482,6 +494,8 @@ fn handleTable(device_tree: *DeviceTree, hal: Hal, sdt_physical: u64) !void { } else if (std.mem.eql(u8, &sig, &HPET)) { try parseHpet(device_tree, hal, header); } else if (std.mem.eql(u8, &sig, &FACP)) { + fadt_physical = sdt_physical; + fadt_length = header.length; parseFadt(header); } else if (std.mem.eql(u8, &sig, &SPCR)) { parseSpcr(header); diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index b2a1398..9154710 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -152,6 +152,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { acpiReportTest(boot_information); } else if (eql(case, "acpi-ps2")) { acpiReportTest(boot_information); // same spawn; the harness regex differs + } else if (eql(case, "power-button")) { + acpiReportTest(boot_information); // boot the manager (spawns the acpi service); harness injects the button } else if (eql(case, "initial-ramdisk")) { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index 84e6a27..6132dfd 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -4,13 +4,11 @@ //! grant, a broad irq window, the SCI), and runs the **shared AML module** in //! ring 3 — the same parser and interpreter the kernel uses. //! -//! M20.2 (this increment): after parsing, walk the namespace and, for each -//! present Device with a hardware id (`_HID`), evaluate its current resource -//! settings (`_CRS`) through a ring-3 `Hal` (port I/O over the claimed node), -//! register it under the acpi-tables node (its I/O ports and IRQs contained by -//! the node's broad grants), and report it to the device manager with its -//! EISA-decoded hid as identity. Matching those reports to drivers (ps2-bus) -//! and retiring the kernel's own device build follow in M20.3. +//! It also owns the **event side** (M21): it registers the domain-named `.power` +//! service, binds the SCI (System Control Interrupt), and on a power-button +//! fixed event publishes `power_button` to subscribers — and on init's request +//! writes S5 to power the machine off. The device discovery (M20) and the event +//! handling both run in one `runtime.service.run` loop. const std = @import("std"); const runtime = @import("runtime"); @@ -18,6 +16,7 @@ const aml = @import("aml"); const acpi_ids = @import("acpi-ids"); const device = runtime.device; const protocol = runtime.device_manager_protocol; +const power = runtime.power_protocol; /// AML opcode/prefix bytes by name (`zero_opcode`, `byte_prefix`, …) — so the `_HID` /// integer decode names the opcodes instead of bare 0x0A/0x0B/… (docs/coding-standards.md). const opcodes = aml.opcodes; @@ -31,6 +30,38 @@ fn writeLine(comptime fmt: []const u8, arguments: anytype) void { // window — the Hal routes every port access through this one claim. var node_id: u64 = 0; var io_resource_index: u64 = 0; +// The SCI's irq resource index on the node (the len-1 irq, distinct from the +// broad [0,256) window), for irqBind / irqAck. +var sci_resource_index: u64 = 0; +var has_sci = false; + +// PM1 event/control and GPE register ports, read from the FADT copy the kernel +// publishes on the node (M21). Port 0 means absent. +var pm1a_evt: u16 = 0; +var pm1b_evt: u16 = 0; +var pm1_evt_len: u8 = 0; +var pm1a_cnt: u16 = 0; +var pm1b_cnt: u16 = 0; +var gpe0_blk: u16 = 0; +var gpe0_len: u8 = 0; +var gpe1_blk: u16 = 0; +var gpe1_len: u8 = 0; +var smi_cmd: u16 = 0; +var acpi_enable_value: u8 = 0; +var s5_slp_typ_a: u8 = 0; +var s5_slp_typ_b: u8 = 0; +var s5_valid = false; + +// PM1 event-register bits (ACPI): PWRBTN in the status/enable word is bit 8; +// the control word's SCI_EN is bit 0; SLP_EN is bit 13. +const pwrbtn_bit: u16 = 1 << 8; +const sci_en_bit: u32 = 1 << 0; +const slp_en: u32 = 1 << 13; + +// The `.power` subscribers: endpoints handed over as capabilities, each +// receiving events as buffered messages. Dropped on a failed send. +const maximum_subscribers = 8; +var subscribers: [maximum_subscribers]?runtime.ipc.Handle = .{null} ** maximum_subscribers; // Pass-1 registration record (see main): what pass 2 reports. const Registered = struct { hid: [8]u8 = .{0} ** 8, hid_len: usize = 0, device_id: u64 = 0, resource_count: u64 = 0 }; @@ -85,22 +116,34 @@ pub fn main(init: runtime.process.Init) void { return; } - // Map each memory resource (an AML blob) and note the io_port resource. + // Map the node's resources: the AML blobs (bytecode), the FADT (intact + // "FACP" header — decision 3), the io_port grant, and the SCI irq. var blocks: [8][]const u8 = undefined; var block_count: usize = 0; var found_io = false; + var fadt: ?[]const u8 = null; for (node.resources[0..@intCast(node.resource_count)], 0..) |resource, index| { if (resource.kind == @intFromEnum(device.ResourceKind.io_port) and !found_io) { io_resource_index = index; found_io = true; continue; } + if (resource.kind == @intFromEnum(device.ResourceKind.irq) and resource.len == 1) { + sci_resource_index = index; + has_sci = true; + continue; + } if (resource.kind != @intFromEnum(device.ResourceKind.memory)) continue; const base = device.mmioMap(node_id, index) orelse continue; const pointer: [*]const u8 = @ptrFromInt(base); - blocks[block_count] = pointer[0..@intCast(resource.len)]; + const bytes = pointer[0..@intCast(resource.len)]; + if (bytes.len >= 4 and std.mem.eql(u8, bytes[0..4], "FACP")) { + fadt = bytes; + continue; + } + if (block_count == blocks.len) continue; + blocks[block_count] = bytes; block_count += 1; - if (block_count == blocks.len) break; } if (block_count == 0) { _ = runtime.system.write("acpi: no AML blobs on the node\n"); @@ -124,29 +167,45 @@ pub fn main(init: runtime.process.Init) void { while (true) runtime.system.sleep(1000); } - // Register + report the present _HID devices (M20.2). - var arena = std.heap.ArenaAllocator.init(runtime.allocator()); - var interpreter = aml.Interpreter.init(&namespace, .{ + // Register + report the present _HID devices (M20), then set up the power + // event side (M21), then serve — all in one harness loop. The interpreter + // and namespace outlive this frame (static), so the harness callbacks can + // reach them. + interpreter_arena = std.heap.ArenaAllocator.init(runtime.allocator()); + persistent_namespace = namespace; + global_interpreter = aml.Interpreter.init(&persistent_namespace, .{ .mapMmio = halMapMmio, .pioRead = halPioRead, .pioWrite = halPioWrite, - }, arena.allocator()); + }, interpreter_arena.allocator()); - // Pass 1: register every present _HID device under acpi-tables, remembering - // each (hid, device id). Pass 2: report them all. Registering before any - // report reaches the manager means a driver it spawns on the first report - // already sees the whole set (no keyboard-before-mouse race for ps2-bus). + readFadt(fadt); + s5_valid = readSleepS5(&persistent_namespace); + + runtime.service.run(power.message_maximum, .{ + .service = .power, + .init = onInit, + .on_message = onMessage, + .on_notification = onNotification, + }); +} + +// Static so the harness callbacks (which run after main's stack frame is gone) +// can reach the namespace and interpreter. +var persistent_namespace: aml.Namespace = undefined; +var global_interpreter: aml.Interpreter = undefined; +var interpreter_arena: std.heap.ArenaAllocator = undefined; + +/// Startup under the harness: register + report the discovered devices to the +/// manager (M20), then enable ACPI mode and arm the power button (M21). +fn onInit(endpoint: runtime.ipc.Handle) bool { registered_count = 0; - walkDevices(namespace.root, &interpreter); + walkDevices(persistent_namespace.root, &global_interpreter); const manager = runtime.ipc.lookup(.device_manager); var i: usize = 0; while (i < registered_count) : (i += 1) { const entry = registered[i]; - // Append the _HID's human-readable name when it is a known standard PnP/ACPI - // id (e.g. PNP0303 -> "PS/2 Keyboard"), so the boot log says what each - // reported device actually is. The description trails the existing fields so - // the acpi-report/acpi-ps2 matchers still see " (device N, M resources)". const hid = entry.hid[0..entry.hid_len]; const desc = acpi_ids.description(hid); if (desc.len != 0) @@ -154,12 +213,7 @@ pub fn main(init: runtime.process.Init) void { else writeLine("acpi: reported {s} (device {d}, {d} resources)\n", .{ hid, entry.device_id, entry.resource_count }); if (manager) |h| { - var report = protocol.ChildAdded{ - .parent = node_id, - .bus_address = entry.device_id, - .identity = 0, - .device_id = entry.device_id, - }; + var report = protocol.ChildAdded{ .parent = node_id, .bus_address = entry.device_id, .identity = 0, .device_id = entry.device_id }; @memcpy(report.hid[0..entry.hid_len], entry.hid[0..entry.hid_len]); var reply: [protocol.message_maximum]u8 = undefined; _ = runtime.ipc.call(h, std.mem.asBytes(&report), &reply) catch {}; @@ -167,9 +221,164 @@ pub fn main(init: runtime.process.Init) void { } writeLine("acpi: reported {d} device(s) to the manager\n", .{registered_count}); - // Stay resident: the claim holds, and the service is here to grow into the - // supervised discoverer (M20.3, then the M21 event side on the SCI). - while (true) runtime.system.sleep(1000); + armPowerButton(endpoint); + return true; +} + +// --- power event side (M21) --------------------------------------------------- + +/// Read the PM1 event/control and GPE register ports plus the SMI enable pair +/// from the FADT copy on the node. Offsets are from the FADT table start (the +/// SDT header is the first 36 bytes). Prefers the 32-bit port fields; QEMU's +/// FADT populates them. +fn readFadt(fadt: ?[]const u8) void { + const f = fadt orelse { + _ = runtime.system.write("acpi: no FADT on the node — power events off\n"); + return; + }; + smi_cmd = @truncate(rd32(f, 48)); + acpi_enable_value = f[52]; + pm1a_evt = @truncate(rd32(f, 56)); + pm1b_evt = @truncate(rd32(f, 60)); + pm1a_cnt = @truncate(rd32(f, 64)); + pm1b_cnt = @truncate(rd32(f, 68)); + gpe0_blk = @truncate(rd32(f, 80)); + gpe1_blk = @truncate(rd32(f, 84)); + pm1_evt_len = if (f.len > 88) f[88] else 4; + gpe0_len = if (f.len > 92) f[92] else 0; + gpe1_len = if (f.len > 93) f[93] else 0; +} + +fn readSleepS5(ns: *aml.Namespace) bool { + const st = aml.sleepState(ns, 5) orelse return false; + s5_slp_typ_a = st.slp_typ_a; + s5_slp_typ_b = st.slp_typ_b; + return true; +} + +/// Enable ACPI mode if the firmware isn't already in it, then bind the SCI and +/// set PWRBTN_EN so the power button raises an interrupt we can see. +fn armPowerButton(endpoint: runtime.ipc.Handle) void { + if (pm1a_cnt != 0 and (halPioRead(2, pm1a_cnt) & sci_en_bit) == 0 and smi_cmd != 0) { + // Switch to ACPI mode: write ACPI_ENABLE to the SMI command port, then + // spin (bounded) until SCI_EN latches. + halPioWrite(1, smi_cmd, acpi_enable_value); + var tries: u32 = 0; + while (tries < 1000 and (halPioRead(2, pm1a_cnt) & sci_en_bit) == 0) : (tries += 1) { + runtime.system.sleep(1); + } + } + if (!has_sci) { + _ = runtime.system.write("acpi: no SCI resource — power button unavailable\n"); + return; + } + if (!device.irqBind(node_id, sci_resource_index, endpoint)) { + _ = runtime.system.write("acpi: SCI irq_bind failed\n"); + return; + } + // PWRBTN_EN lives in the PM1 enable register at evt_blk + evt_len/2. + if (pm1a_evt != 0) { + const en_port = pm1a_evt + pm1_evt_len / 2; + halPioWrite(2, en_port, @as(u16, @truncate(halPioRead(2, en_port))) | pwrbtn_bit); + } + if (pm1b_evt != 0) { + const en_port = pm1b_evt + pm1_evt_len / 2; + halPioWrite(2, en_port, @as(u16, @truncate(halPioRead(2, en_port))) | pwrbtn_bit); + } + _ = runtime.system.write("acpi: power button armed\n"); +} + +/// The SCI fired. Read PM1 status; a set PWRBTN_STS is the power button — clear +/// it (write-1), publish, log. Any other set status is cleared and logged +/// (GPE/Notify dispatch is M21.2). Always re-arm the line. +fn onSci() void { + var handled = false; + inline for (.{ pm1a_evt, pm1b_evt }) |evt_port| { + if (evt_port != 0) { + const sts: u16 = @truncate(halPioRead(2, evt_port)); + if (sts & pwrbtn_bit != 0) { + halPioWrite(2, evt_port, pwrbtn_bit); // write-1-to-clear + handled = true; + } else if (sts != 0) { + halPioWrite(2, evt_port, sts); // clear whatever else latched + } + } + } + if (handled) { + _ = runtime.system.write("power: button pressed\n"); + publishButton(); + } + _ = device.irqAck(node_id, sci_resource_index); +} + +fn publishButton() void { + const event = power.EventMessage{ .event = @intFromEnum(power.Event.power_button) }; + publishEvent(std.mem.asBytes(&event)); +} + +fn publishEvent(bytes: []const u8) void { + for (&subscribers) |*slot| { + if (slot.*) |handle| { + if (!runtime.ipc.send(handle, bytes)) slot.* = null; + } + } +} + +/// Enter S5 (soft off): write SLP_TYP|SLP_EN to the PM1 control register(s). +/// Mirrors the kernel's power.zig sleepValue. Only reached from a PID-1 +/// shutdown request (M21.3). +fn enterS5() void { + if (!s5_valid or pm1a_cnt == 0) { + _ = runtime.system.write("power: S5 unavailable\n"); + return; + } + _ = runtime.system.write("power: entering S5\n"); + halPioWrite(2, pm1a_cnt, (@as(u32, s5_slp_typ_a & 0x7) << 10) | slp_en); + if (pm1b_cnt != 0) halPioWrite(2, pm1b_cnt, (@as(u32, s5_slp_typ_b & 0x7) << 10) | slp_en); + // If control returns, the write did not take — say so instead of hanging. + runtime.system.sleep(500); + _ = runtime.system.write("power: S5 write did not take\n"); +} + +// --- harness callbacks -------------------------------------------------------- + +fn onNotification(badge: u64) void { + // The only notification the service binds is the SCI (an IRQ badge). + _ = badge; + onSci(); +} + +/// The `.power` protocol: subscribe (endpoint as the call's capability), +/// shutdown (PID 1 only). Device discovery uses a different endpoint (the +/// device manager's), so nothing here handles ChildAdded. +fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize { + if (message.len < 1) return 0; + switch (message[0]) { + @intFromEnum(power.Operation.subscribe) => { + var status: i32 = -1; + if (capability) |handle| { + for (&subscribers) |*slot| { + if (slot.* == null) { + slot.* = handle; + status = 0; + break; + } + } + } + const r = power.Reply{ .status = status }; + @memcpy(reply[0..@sizeOf(power.Reply)], std.mem.asBytes(&r)); + return @sizeOf(power.Reply); + }, + @intFromEnum(power.Operation.shutdown) => { + // Only PID 1 (init), which has already stopped everything else. + const status: i32 = if (sender == 1) 0 else -1; + const r = power.Reply{ .status = status }; + @memcpy(reply[0..@sizeOf(power.Reply)], std.mem.asBytes(&r)); + if (sender == 1) enterS5(); + return @sizeOf(power.Reply); + }, + else => return 0, + } } /// Depth-first walk: register + report each present device with a _HID, then diff --git a/system/services/power/protocol.zig b/system/services/power/protocol.zig new file mode 100644 index 0000000..8d1a720 --- /dev/null +++ b/system/services/power/protocol.zig @@ -0,0 +1,68 @@ +//! The power protocol (docs/m21-plan.md): system power's domain-named surface, +//! registered under `ServiceId.power`. On x86 the acpi service serves it; on +//! ARM a PSCI/mailbox service will register the same id — subscribers never +//! learn which firmware they are on (m19-m20-plan.md decision 7). The +//! vfs-protocol pattern: extern-struct messages, a version, reserved fields. + +/// The protocol version a client states nowhere yet — reserved for the day a +/// handshake needs it; requests carry it so a mismatch can be refused loudly. +pub const version: u16 = 1; + +pub const Operation = enum(u8) { + /// Subscribe to power events: the subscriber's endpoint rides as the + /// call's capability (the input/device-manager pattern); events arrive on + /// it as buffered messages carrying an `EventMessage`. + subscribe = 1, + /// Orderly shutdown's last step: enter S5. Accepted only from PID 1 + /// (init) — the process that has already run the stop sequence over + /// everything else. + shutdown = 2, + /// The published event payload (never sent *to* the service). + event = 3, +}; + +/// What happened. The vocabulary is hardware-neutral: a lid is a lid whether +/// ACPI or a PSCI mailbox reported it. +pub const Event = enum(u8) { + power_button = 1, + lid = 2, + ac = 3, + battery = 4, + /// A device notification that maps to none of the named events — the + /// `code` and `hid` fields say which device and what code. + notify = 5, +}; + +pub const Subscribe = extern struct { + operation: u8 = @intFromEnum(Operation.subscribe), + reserved0: u8 = 0, + version: u16 = version, + reserved1: u32 = 0, +}; + +pub const Shutdown = extern struct { + operation: u8 = @intFromEnum(Operation.shutdown), + reserved0: u8 = 0, + version: u16 = version, + reserved1: u32 = 0, +}; + +/// A published event, as the buffered-message payload subscribers receive. +pub const EventMessage = extern struct { + operation: u8 = @intFromEnum(Operation.event), + /// An Event value. + event: u8, + reserved0: u16 = 0, + /// The device notification code (Notify's second argument), or 0. + code: u32 = 0, + /// The notifying device's hardware id (EISA-decoded), or all zero. + hid: [8]u8 = .{0} ** 8, +}; + +pub const Reply = extern struct { + status: i32, + reserved: u32 = 0, +}; + +/// Upper bound on any message in this protocol — sizes endpoint buffers. +pub const message_maximum = 64; diff --git a/test/qemu_test.py b/test/qemu_test.py index 26bf613..0248027 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -292,6 +292,15 @@ CASES = [ r"device-manager: spawned ps2-bus[\s\S]*" r"ps2-bus: keyboard driver attached", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # M21.1: the SCI + power button. Boot the manager (which spawns the acpi + # service); ~4s in, QMP system_powerdown raises the ACPI power-button fixed + # event; the service's SCI handler must log the press (docs/m21-plan.md). + {"name": "power-button", + "smp": 4, + "timeout": 60, + "qmp_after": {"delay": 4, "command": "system_powerdown"}, + "expect": r"power: button pressed", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # M20.2: the acpi service evaluates _CRS/_STA in ring 3 and registers + # reports its _HID devices — the two PS/2 nodes must appear with resources # (keyboard: io 0x60/0x64 + IRQ = 3; mouse: IRQ = 1) (docs/m19-m20-plan.md). @@ -342,6 +351,7 @@ CASES = [ # The initial_ramdisk: the loader ferries a bundle of user binaries; the kernel parses # it and spawns each as a ring-3 process (here the VFS-server stub heartbeats). {"name": "initial-ramdisk", + "timeout": 60, # the acpi service's boot-time SCI setup can push the marker past 30s under load "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The user-space VFS: a client opens/writes/reads a file through the rt file From 767a2a9a7c94bec392d29057fa35b94fb415e1e7 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:44:58 +0100 Subject: [PATCH 3/5] Notify dispatch and GPE handlers (M21.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AML interpreter now handles the Notify opcode (0x86, previously unhandled): it resolves the target device, evaluates the code, and records the pair in a bounded per-evaluate queue the caller drains with takeNotifications. A host unit test with hand-encoded AML — a method that issues Notify(DEV_, 0x80) — proves the device and code come back; aml.zig joins the zig build test loop so the interpreter is covered on the host. The acpi service's SCI handler now services general-purpose events too: for each set-and-enabled GPE bit it evaluates the \_GPE._Lxx (level) or _Exx (edge) handler method, drains the Notify queue that produced, and publishes a domain event per notified device — PNP0C0A battery, ACPI0003 AC, PNP0C0D lid, else generic notify — then clears the status bit and acks. The embedded controller's _Qxx queries are out of scope (hardware track). QEMU raises no GPEs on this config, so the QEMU suite is the regression net (the power button still works with GPE servicing in the path); correctness is the unit test. Suite 59/59. --- build.zig | 1 + docs/m21-plan.md | 14 +++--- system/devices/aml/aml.zig | 28 ++++++++++++ system/devices/aml/interpreter.zig | 41 ++++++++++++++++++ system/drivers/ps2-bus/keyboard.zig | 22 +++++----- system/drivers/ps2-bus/mouse.zig | 20 ++++----- system/drivers/ps2-bus/ps2-bus.zig | 64 ++++++++++++++-------------- system/services/acpi/acpi.zig | 66 +++++++++++++++++++++++++++++ 8 files changed, 197 insertions(+), 59 deletions(-) diff --git a/build.zig b/build.zig index 1f404f5..88406b9 100644 --- a/build.zig +++ b/build.zig @@ -583,6 +583,7 @@ pub fn build(b: *std.Build) void { "system/devices/device-abi.zig", "system/devices/pci-class.zig", // class/subclass/prog-IF name decoding "system/devices/acpi-ids.zig", // _HID name decoding + "system/devices/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch (M21) "system/devices/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings "system/devices/usb-ids.zig", // class/subclass/protocol code assignments "library/mmio/mmio.zig", // barriers assemble + registers round-trip diff --git a/docs/m21-plan.md b/docs/m21-plan.md index 7ad0928..af6b946 100644 --- a/docs/m21-plan.md +++ b/docs/m21-plan.md @@ -84,12 +84,14 @@ auto-merge to main when the branch is green; keep the branch; push everything. logs `power: button pressed`, publishes `power_button`, acks. Scenario `power-button` injects a real `system_powerdown` via QMP; initial-ramdisk timeout 30→60s for the service's added boot work; suite 59/59). -- [ ] **M21.2** — Notify + GPE dispatch: interpreter handles `notify_opcode` - into a bounded queue drained after evaluate(); on GPE status bits the - service evaluates `\_GPE._Lxx`/`_Exx`, maps notified nodes to events - (PNP0C0A→battery, ACPI0003→ac, PNP0C0D→lid, else generic), clears - GPE_STS, acks. EC `_Qxx` explicitly out (hardware track). Host unit - tests for Notify in aml.zig; aml.zig joins the `zig build test` loop. +- [x] **M21.2** — Notify + GPE dispatch (interpreter handles `notify_opcode` + into a bounded queue, cleared per-evaluate, drained via + `takeNotifications`; the service walks GPE status/enable bytes, evaluates + `\_GPE._Lxx`/`_Exx` per active bit, maps notified nodes to events + (battery/ac/lid/generic), clears GPE_STS write-1, acks. EC `_Qxx` out. + Host unit test with hand-encoded AML proves the queue; aml.zig joined the + `zig build test` loop. QEMU raises no GPEs — suite is regression net, + 59/59). - [ ] **M21.3** — orderly shutdown: init keeps child ids (spawnSupervised + exit endpoint), binds signals, subscribes to `.power`; on `power_button` logs `init: shutting down`, runs `stop(child, 2000, endpoint)` in diff --git a/system/devices/aml/aml.zig b/system/devices/aml/aml.zig index 8ad4b66..8206c07 100644 --- a/system/devices/aml/aml.zig +++ b/system/devices/aml/aml.zig @@ -230,3 +230,31 @@ test "interpreter runs a method with args, arithmetic, and control flow" { const lo = try interpreter.evaluate(tst, &.{.{ .integer = 2 }}); // 2+5=7 !> 10 -> 0 try std.testing.expectEqual(@as(u64, 0), try lo.asInteger()); } + +test "interpreter records Notify(device, code)" { + // Device(DEV_) { Name(_HID, 0x030AD041) } // PNP0A03-ish placeholder + // Method(TST_, 0) { Notify(DEV_, 0x80); Return(Zero) } + // Encoded: a Device holding a Name, then a Method issuing Notify on it. + const blob = [_]u8{ + 0x5B, 0x82, 0x0F, 0x44, 0x45, 0x56, 0x5F, // Device(DEV_) len=0x0F (pkglen + DEV_ + Name) + 0x08, 0x5F, 0x48, 0x49, 0x44, 0x0C, 0x41, 0xD0, 0x0A, 0x03, // Name(_HID, DWord 0x030AD041) + 0x14, 0x0F, 0x54, 0x53, 0x54, 0x5F, 0x00, // Method(TST_, 0) len=0x0F (pkglen + TST_ + flags + body) + 0x86, 0x44, 0x45, 0x56, 0x5F, 0x0A, 0x80, // Notify(DEV_, 0x80) + 0xA4, 0x00, // Return(Zero) + }; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var result = try parse(arena.allocator(), &.{&blob}); + const namespace = &result.namespace; + const tst = namespace.resolve(namespace.root, false, 0, &.{.{ 'T', 'S', 'T', '_' }}) orelse return error.NoMethod; + const dev = namespace.resolve(namespace.root, false, 0, &.{.{ 'D', 'E', 'V', '_' }}) orelse return error.NoDevice; + + var interpreter = Interpreter.init(namespace, .{ .mapMmio = noMap, .pioRead = noRead, .pioWrite = noWrite }, arena.allocator()); + _ = try interpreter.evaluate(tst, &.{}); + + const events = interpreter.takeNotifications(); + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqual(dev, events[0].node); + try std.testing.expectEqual(@as(u64, 0x80), events[0].code); +} diff --git a/system/devices/aml/interpreter.zig b/system/devices/aml/interpreter.zig index bef89d8..a75cc03 100644 --- a/system/devices/aml/interpreter.zig +++ b/system/devices/aml/interpreter.zig @@ -141,6 +141,9 @@ const Frame = struct { /// A CreateField binding: a name that indexes into a buffer object. const BufferField = struct { buffer: *Node, byte_off: usize, bit_width: u32 }; +/// One Notify(device, code) the interpreter executed. +pub const NotifyEvent = struct { node: *Node, code: u64 }; + pub const Interpreter = struct { namespace: *Namespace, hal: Hal, @@ -149,6 +152,11 @@ pub const Interpreter = struct { dynamic_overrides: std.AutoHashMapUnmanaged(*Node, Object) = .{}, /// CreateField bindings active for the current evaluation. fields: std.AutoHashMapUnmanaged(*Node, BufferField) = .{}, + /// Notify(device, code) operations the last evaluation executed — a GPE or + /// EC handler tells the OS "look at this device" this way. Bounded; the + /// caller drains it with `takeNotifications` after `evaluate` (M21). + notify_queue: [16]NotifyEvent = undefined, + notify_count: usize = 0, pub fn init(namespace: *Namespace, hal: Hal, arena: std.mem.Allocator) Interpreter { return .{ .namespace = namespace, .hal = hal, .arena = arena }; @@ -157,6 +165,7 @@ pub const Interpreter = struct { /// Evaluate a namespace object: invoke a Method, read a Name's value, or read a /// Field. Resets per-evaluation runtime state first. pub fn evaluate(self: *Interpreter, node: *Node, args: []const Object) Error!Object { + self.notify_count = 0; self.dynamic_overrides.clearRetainingCapacity(); self.fields.clearRetainingCapacity(); return self.invoke(node, args); @@ -267,6 +276,8 @@ pub const Interpreter = struct { }, opcode.to_buffer_opcode => try self.passThroughUnary(current, frame), + opcode.notify_opcode => try self.notify(current, frame), + opcode.extended_opcode_prefix => try self.ext(current, frame), // CreateXField: source, index, name (bit widths differ by op) @@ -542,6 +553,36 @@ pub const Interpreter = struct { try self.storeInto(current, frame, value); } + /// Notify(SuperName, NotifyValue): resolve the named device, evaluate the + /// code, and record the pair for the caller to dispatch. AML control flow + /// continues (Notify returns nothing). + fn notify(self: *Interpreter, current: *Cursor, frame: *Frame) Error!Object { + const lead = current.peek() orelse return error.Truncated; + var target: ?*Node = null; + if (isNameStart(lead)) { + const name_path = try current.nameString(); + target = self.namespace.resolve(frame.scope, name_path.rooted, name_path.parents, name_path.slice()); + } else { + // A non-name SuperName (Local/Arg holding a reference). + const obj = try self.term(current, frame); + if (obj == .reference) target = obj.reference; + } + const code = try self.evaluateInteger(current, frame); + if (target) |node| { + if (self.notify_count < self.notify_queue.len) { + self.notify_queue[self.notify_count] = .{ .node = node, .code = code }; + self.notify_count += 1; + } + } + return .uninitialized; + } + + /// The Notify events the last `evaluate` produced. Valid until the next + /// `evaluate` clears the queue. + pub fn takeNotifications(self: *Interpreter) []const NotifyEvent { + return self.notify_queue[0..self.notify_count]; + } + fn storeInto(self: *Interpreter, current: *Cursor, frame: *Frame, value: Object) Error!void { const lead = current.peek() orelse return error.Truncated; if (isNameStart(lead)) { diff --git a/system/drivers/ps2-bus/keyboard.zig b/system/drivers/ps2-bus/keyboard.zig index a38f826..2b5ba0f 100644 --- a/system/drivers/ps2-bus/keyboard.zig +++ b/system/drivers/ps2-bus/keyboard.zig @@ -72,17 +72,17 @@ fn modifierWord(modifiers: scancode.ModifierSnapshot) u32 { pub fn main(init: runtime.process.Init) void { const hid = init.arguments.get(1).?; if (hid.len == 0) { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: no HID argument\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: no HID argument\n"); return; } - writeLine("system/drivers/ps2-bus/keyboard: starting for hid {s}\n", .{hid}); + writeLine("/system/drivers/ps2-bus/keyboard: starting for hid {s}\n", .{hid}); const buffer = runtime.allocator().alloc(device.DeviceDescriptor, 64) catch { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: out of memory\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: out of memory\n"); return; }; if (device.findDeviceDescriptorByHid(buffer, hid) == null) { - writeLine("system/drivers/ps2-bus/keyboard: no device for hid {s}\n", .{hid}); + writeLine("/system/drivers/ps2-bus/keyboard: no device for hid {s}\n", .{hid}); return; } @@ -90,38 +90,38 @@ pub fn main(init: runtime.process.Init) void { // absent (as today) it defaults to us. const layout_name = init.arguments.get(2) orelse "us"; const layout = xkb.byName(layout_name) orelse xkb.us; - writeLine("system/drivers/ps2-bus/keyboard: layout {s}\n", .{layout.name}); + writeLine("/system/drivers/ps2-bus/keyboard: layout {s}\n", .{layout.name}); // Attach to the bus: hand it our endpoint, and it forwards every byte the // keyboard sends (it owns the controller; we own the decoding). const bus = lookupBus() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: ps2-bus service unavailable\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: ps2-bus service unavailable\n"); return; }; const endpoint = ipc.createIpcEndpoint() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: no endpoint\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: no endpoint\n"); return; }; var attach = ps2.AttachRequest{ .device_type = @intFromEnum(ps2.DeviceType.keyboard) }; var attach_reply: [@sizeOf(ps2.AttachReply)]u8 = undefined; const attached = ipc.callCap(bus, std.mem.asBytes(&attach), &attach_reply, endpoint) catch { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: attach call failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: attach call failed\n"); return; }; if (attached.len < @sizeOf(ps2.AttachReply) or std.mem.bytesToValue(ps2.AttachReply, attach_reply[0..@sizeOf(ps2.AttachReply)]).status != @intFromEnum(ps2.AttachStatus.ok)) { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: attach refused\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: attach refused\n"); return; } // Broadcast keyboard events through the input service so programs can listen // for them (docs/input.md). var source = runtime.input.connectSource() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: input service unavailable\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: input service unavailable\n"); return; }; - _ = runtime.system.write("system/drivers/ps2-bus/keyboard: ok\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/keyboard: ok\n"); var decoder = scancode.Decoder{}; var state = scancode.KeyboardState{}; diff --git a/system/drivers/ps2-bus/mouse.zig b/system/drivers/ps2-bus/mouse.zig index c70d0a0..c106eab 100644 --- a/system/drivers/ps2-bus/mouse.zig +++ b/system/drivers/ps2-bus/mouse.zig @@ -51,50 +51,50 @@ pub fn main(init: runtime.process.Init) void { const hid = init.arguments.get(1).?; if (hid.len == 0) { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: no HID argument\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: no HID argument\n"); return; } - writeLine("system/drivers/ps2-bus/mouse: starting for hid {s}\n", .{hid}); + writeLine("/system/drivers/ps2-bus/mouse: starting for hid {s}\n", .{hid}); const buffer = runtime.allocator().alloc(device.DeviceDescriptor, 64) catch { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: out of memory\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: out of memory\n"); return; }; if (device.findDeviceDescriptorByHid(buffer, hid) == null) { - writeLine("system/drivers/ps2-bus/mouse: no device for hid {s}\n", .{hid}); + writeLine("/system/drivers/ps2-bus/mouse: no device for hid {s}\n", .{hid}); return; } // Attach to the bus: hand it our endpoint, and it forwards every byte the // mouse sends (it owns the controller; we own the decoding). const bus = lookupBus() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: ps2-bus service unavailable\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: ps2-bus service unavailable\n"); return; }; const endpoint = ipc.createIpcEndpoint() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: no endpoint\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: no endpoint\n"); return; }; var attach = ps2.AttachRequest{ .device_type = @intFromEnum(ps2.DeviceType.mouse) }; var attach_reply: [@sizeOf(ps2.AttachReply)]u8 = undefined; const attached = ipc.callCap(bus, std.mem.asBytes(&attach), &attach_reply, endpoint) catch { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: attach call failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: attach call failed\n"); return; }; if (attached.len < @sizeOf(ps2.AttachReply) or std.mem.bytesToValue(ps2.AttachReply, attach_reply[0..@sizeOf(ps2.AttachReply)]).status != @intFromEnum(ps2.AttachStatus.ok)) { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: attach refused\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: attach refused\n"); return; } // Broadcast mouse events through the input service so programs can listen // for them (docs/input.md). var source = runtime.input.connectSource() orelse { - _ = runtime.system.write("system/drivers/ps2-bus/mouse: input service unavailable\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: input service unavailable\n"); return; }; - _ = runtime.system.write("system/drivers/ps2-bus/mouse: ok\n"); + _ = runtime.system.write("/system/drivers/ps2-bus/mouse: ok\n"); var assembler = mouse_packet.Assembler{}; var buttons: u32 = 0; diff --git a/system/drivers/ps2-bus/ps2-bus.zig b/system/drivers/ps2-bus/ps2-bus.zig index 2438a92..8f258ad 100644 --- a/system/drivers/ps2-bus/ps2-bus.zig +++ b/system/drivers/ps2-bus/ps2-bus.zig @@ -30,19 +30,19 @@ fn writeLine(comptime fmt: []const u8, arguments: anytype) void { /// attaches, or null if nothing was spawned. fn spawnIdentifiedDriver(controller: ps2.Controller, port: ps2.Port) ?ps2.DeviceType { const device_type = controller.identifyDevice(port) orelse { - writeLine("system/drivers/ps2-bus: identify timed out on port {s}\n", .{@tagName(port)}); + writeLine("/system/drivers/ps2-bus: identify timed out on port {s}\n", .{@tagName(port)}); return null; }; const driver_name = device_type.driverName() orelse { - writeLine("system/drivers/ps2-bus: unrecognized device on port {s}\n", .{@tagName(port)}); + writeLine("/system/drivers/ps2-bus: unrecognized device on port {s}\n", .{@tagName(port)}); return null; }; const hid = device_type.hid() orelse ""; if (runtime.system.spawnWithArguments(driver_name, &.{hid}) != null) { - writeLine("system/drivers/ps2-bus: port {s} is a {s}, spawned {s}\n", .{ @tagName(port), hid, driver_name }); + writeLine("/system/drivers/ps2-bus: port {s} is a {s}, spawned {s}\n", .{ @tagName(port), hid, driver_name }); return device_type; } - writeLine("system/drivers/ps2-bus: failed to spawn {s}\n", .{driver_name}); + writeLine("/system/drivers/ps2-bus: failed to spawn {s}\n", .{driver_name}); return null; } @@ -83,7 +83,7 @@ fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize { const device_type = maybe_type orelse continue; if (@intFromEnum(device_type) != request.device_type) continue; port_endpoints[port_index] = endpoint; - writeLine("system/drivers/ps2-bus: {s} driver attached\n", .{@tagName(device_type)}); + writeLine("/system/drivers/ps2-bus: {s} driver attached\n", .{@tagName(device_type)}); return reply.write(out, .ok); } return reply.write(out, .no_such_device); @@ -91,7 +91,7 @@ fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize { pub fn main() void { const buffer = runtime.allocator().alloc(device.DeviceDescriptor, 64) catch { - _ = runtime.system.write("system/drivers/ps2-bus: out of memory\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: out of memory\n"); return; }; @@ -103,16 +103,16 @@ pub fn main() void { // is on which port is decided later by identify, not by this HID. const maybe_controller_device_descriptor = device.findDeviceDescriptorByHid(buffer, acpi_ids.HardwareId.ps2_keyboard.hid()); if (maybe_controller_device_descriptor) |controller_device_descriptor| { - _ = runtime.system.write("system/drivers/ps2-bus: found PS/2 controller\n"); - _ = runtime.system.write("system/drivers/ps2-bus: initializing controller\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: found PS/2 controller\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: initializing controller\n"); if (!device.claim(controller_device_descriptor.id)) { - _ = runtime.system.write("system/drivers/ps2-bus: unable to claim controller \n"); + _ = runtime.system.write("/system/drivers/ps2-bus: unable to claim controller \n"); return; } const controller = ps2.Controller.init(controller_device_descriptor) orelse { - _ = runtime.system.write("system/drivers/ps2-bus: controller is missing its IO ports\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller is missing its IO ports\n"); return; }; maybe_controller = controller; @@ -123,7 +123,7 @@ pub fn main() void { controller.flushOutputBuffer(); const current = controller.readConfigurationByte() orelse { - _ = runtime.system.write("system/drivers/ps2-bus: controller configuration timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller configuration timed out\n"); return; }; @@ -132,49 +132,49 @@ pub fn main() void { ps2.configuration_first_port_translation); if (controller.writeConfigurationByte(update) == null) { - _ = runtime.system.write("system/drivers/ps2-bus: controller configuration timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller configuration timed out\n"); return; } if (controller.performSelfTest()) |reply| { if (reply != ps2.response_controller_test_passed) { - _ = runtime.system.write("system/drivers/ps2-bus: perform controller self test failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: perform controller self test failed\n"); return; } } else { - _ = runtime.system.write("system/drivers/ps2-bus: controller self test timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller self test timed out\n"); return; } has_two_channels = controller.hasTwoChannels() orelse { - _ = runtime.system.write("system/drivers/ps2-bus: controller channels timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller channels timed out\n"); return; }; if (has_two_channels) { - _ = runtime.system.write("system/drivers/ps2-bus: has two channels\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: has two channels\n"); // keep the bus quiet until we have tested the ports and are ready to use them controller.disablePort(.two); } else { - _ = runtime.system.write("system/drivers/ps2-bus: has one channel\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: has one channel\n"); } // interface tests: always test port 1, test port 2 only if it exists const port_one_works = (controller.testPort(.one) orelse { - _ = runtime.system.write("system/drivers/ps2-bus: port 1 test timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: port 1 test timed out\n"); return; }) == ps2.response_port_test_passed; var port_two_works = false; if (has_two_channels) { port_two_works = (controller.testPort(.two) orelse { - _ = runtime.system.write("system/drivers/ps2-bus: port 2 test timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: port 2 test timed out\n"); return; }) == ps2.response_port_test_passed; } if (!port_one_works and !port_two_works) { - _ = runtime.system.write("system/drivers/ps2-bus: no usable ports\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: no usable ports\n"); return; } @@ -188,16 +188,16 @@ pub fn main() void { // abort bring-up of the other one if (port_one_works) { if (controller.resetDevice(.one)) |passed| { - if (!passed) _ = runtime.system.write("system/drivers/ps2-bus: port 1 device reset failed\n"); + if (!passed) _ = runtime.system.write("/system/drivers/ps2-bus: port 1 device reset failed\n"); } else { - _ = runtime.system.write("system/drivers/ps2-bus: port 1 device reset timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: port 1 device reset timed out\n"); } } if (port_two_works) { if (controller.resetDevice(.two)) |passed| { - if (!passed) _ = runtime.system.write("system/drivers/ps2-bus: port 2 device reset failed\n"); + if (!passed) _ = runtime.system.write("/system/drivers/ps2-bus: port 2 device reset failed\n"); } else { - _ = runtime.system.write("system/drivers/ps2-bus: port 2 device reset timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: port 2 device reset timed out\n"); } } @@ -207,13 +207,13 @@ pub fn main() void { if (port_one_works) port_device_types[@intFromEnum(ps2.Port.one)] = spawnIdentifiedDriver(controller, .one); if (port_two_works) port_device_types[@intFromEnum(ps2.Port.two)] = spawnIdentifiedDriver(controller, .two); } else { - _ = runtime.system.write("system/drivers/ps2-bus: no PS/2 controller found\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: no PS/2 controller found\n"); return; } const controller = maybe_controller.?; const interrupt_index = maybe_interrupt_index orelse { - _ = runtime.system.write("system/drivers/ps2-bus: controller is missing its IRQ\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller is missing its IRQ\n"); return; }; @@ -221,11 +221,11 @@ pub fn main() void { // well-known id so the children can find it, the way input subscribers find // the input service. const endpoint = ipc.createIpcEndpoint() orelse { - _ = runtime.system.write("system/drivers/ps2-bus: no endpoint\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: no endpoint\n"); return; }; if (!ipc.register(.ps2_bus, endpoint)) { - _ = runtime.system.write("system/drivers/ps2-bus: register failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: register failed\n"); return; } @@ -234,7 +234,7 @@ pub fn main() void { // let the controller raise them — an interrupt with nobody bound is lost. controller.drainOutputBuffer(); if (!device.irqBind(controller.device_id, interrupt_index, endpoint)) { - _ = runtime.system.write("system/drivers/ps2-bus: irq_bind failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: irq_bind failed\n"); return; } @@ -253,21 +253,21 @@ pub fn main() void { .gsi = descriptor.resources[auxiliary_index].start, }; } else { - _ = runtime.system.write("system/drivers/ps2-bus: auxiliary irq_bind failed\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: auxiliary irq_bind failed\n"); } } } } var configuration = controller.readConfigurationByte() orelse { - _ = runtime.system.write("system/drivers/ps2-bus: controller configuration timed out\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: controller configuration timed out\n"); return; }; if (port_device_types[@intFromEnum(ps2.Port.one)] != null) configuration |= ps2.Port.one.interruptBit(); if (maybe_auxiliary_interrupt != null) configuration |= ps2.Port.two.interruptBit(); _ = controller.writeConfigurationByte(configuration); - _ = runtime.system.write("system/drivers/ps2-bus: ok\n"); + _ = runtime.system.write("/system/drivers/ps2-bus: ok\n"); // The forwarding loop: an IRQ1 notification drains the output buffer, routing // each byte to the attached driver of the port it came from; a client message diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index 6132dfd..df8542c 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -308,9 +308,75 @@ fn onSci() void { _ = runtime.system.write("power: button pressed\n"); publishButton(); } + handleGpe(); _ = device.irqAck(node_id, sci_resource_index); } +/// General-purpose events: for each set+enabled GPE bit, evaluate its `\_GPE` +/// handler method (`_Lxx` level / `_Exx` edge), drain the Notify queue the +/// method produced, and publish an event per notified device. Then clear the +/// status bit. QEMU raises no GPEs on this config, so this path is exercised by +/// host unit tests (docs/m21-plan.md decision 5); on real hardware it carries +/// battery/AC/lid. The embedded controller's `_Qxx` queries are out of scope. +fn handleGpe() void { + handleGpeBlock(gpe0_blk, gpe0_len, 0); + handleGpeBlock(gpe1_blk, gpe1_len, gpe0_len * 4); +} + +fn handleGpeBlock(blk: u16, len: u8, gpe_base: u32) void { + if (blk == 0 or len == 0) return; + const status_bytes = len / 2; // status half, then enable half + var byte_index: u8 = 0; + while (byte_index < status_bytes) : (byte_index += 1) { + const sts: u8 = @truncate(halPioRead(1, blk + byte_index)); + const en: u8 = @truncate(halPioRead(1, blk + status_bytes + byte_index)); + const active = sts & en; + if (active == 0) continue; + var bit: u3 = 0; + while (true) : (bit += 1) { + if (active & (@as(u8, 1) << bit) != 0) { + dispatchGpe(gpe_base + @as(u32, byte_index) * 8 + bit); + } + if (bit == 7) break; + } + halPioWrite(1, blk + byte_index, active); // write-1-to-clear the serviced bits + } +} + +/// Evaluate the `\_GPE._L%02X` or `_E%02X` handler for GPE number `n`, then +/// publish an event for each device it notified. +fn dispatchGpe(n: u32) void { + const gpe_scope = aml.Namespace.resolve(&persistent_namespace, persistent_namespace.root, true, 0, &.{seg4("_GPE")}) orelse return; + var name: [4]u8 = .{ '_', 'L', 0, 0 }; + writeHex2(name[2..4], n); + var method = aml.Namespace.childOf(gpe_scope, name); + if (method == null) { + name[1] = 'E'; + method = aml.Namespace.childOf(gpe_scope, name); + } + const m = method orelse return; // no handler — the status bit was already cleared + _ = global_interpreter.evaluate(m, &.{}) catch return; + for (global_interpreter.takeNotifications()) |event| publishNotify(event.node, event.code); +} + +fn publishNotify(node: *aml.Node, code: u64) void { + // Map the notified device's _HID to a domain event where we recognize it. + var hid: [8]u8 = .{0} ** 8; + if (readHid(node, &global_interpreter)) |h| hid = h; + const which: power.Event = if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) .battery else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) .ac else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) .lid else .notify; + var event = power.EventMessage{ .event = @intFromEnum(which), .code = @truncate(code) }; + event.hid = hid; + writeLine("power: notify {s} code {d}\n", .{ hid[0..7], code }); + publishEvent(std.mem.asBytes(&event)); +} + +/// Two lowercase hex digits of `n` into `out[0..2]`. +fn writeHex2(out: []u8, n: u32) void { + const digits = "0123456789ABCDEF"; + out[0] = digits[(n >> 4) & 0xF]; + out[1] = digits[n & 0xF]; +} + fn publishButton() void { const event = power.EventMessage{ .event = @intFromEnum(power.Event.power_button) }; publishEvent(std.mem.asBytes(&event)); From a785efa4a303ff44a7dd784233637a5bd6b119f8 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:56:58 +0100 Subject: [PATCH 4/5] Orderly shutdown: init's stop cascade into ring-3 S5 (M21.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capstone. init becomes a real supervisor: it spawns its boot services supervised against one endpoint that also carries its signals, a re-arming heartbeat timer, and the power events it subscribes to. On the power button (or a terminate signal — same path) it logs the shutdown, runs the M17 stop sequence over its children in reverse spawn order (vfs last), then asks the power service for S5. The acpi service honors a shutdown request from a power subscriber — init is the one subscriber, a soft gate that stands in for 'only the system supervisor may power off' and, unlike a PID-1 check, survives the test harness where the kernel's idle tasks take the early ids. The power service is mechanism (write S5); deciding when to shut down and stopping everything else first is init's policy — the microkernel split applied to poweroff. The orderly-shutdown scenario injects a real QMP power-button event and watches the whole chain compose: button pressed -> init shutting down -> entering S5 -> QEMU powers off. That single scenario proves the M17 lifecycle and the M21 event side compose into a clean shutdown. Suite 60/60. --- docs/m21-plan.md | 17 +++--- system/kernel/tests.zig | 23 ++++++++ system/services/acpi/acpi.zig | 28 +++++++-- system/services/init/init.zig | 107 ++++++++++++++++++++++++++++++---- test/qemu_test.py | 13 +++++ 5 files changed, 164 insertions(+), 24 deletions(-) diff --git a/docs/m21-plan.md b/docs/m21-plan.md index af6b946..e787a02 100644 --- a/docs/m21-plan.md +++ b/docs/m21-plan.md @@ -92,14 +92,15 @@ auto-merge to main when the branch is green; keep the branch; push everything. Host unit test with hand-encoded AML proves the queue; aml.zig joined the `zig build test` loop. QEMU raises no GPEs — suite is regression net, 59/59). -- [ ] **M21.3** — orderly shutdown: init keeps child ids (spawnSupervised + - exit endpoint), binds signals, subscribes to `.power`; on `power_button` - logs `init: shutting down`, runs `stop(child, 2000, endpoint)` in - reverse spawn order, then sends `shutdown` to `.power`; the acpi service - (sender PID 1 only) logs `power: entering S5` and writes SLP_TYP|SLP_EN - from ring 3. Scenario `orderly-shutdown`: boot via init, `qmp_after - system_powerdown`, ordered regex button→shutting-down→entering-S5, pass - on QEMU exit. Docs + memory updated. +- [x] **M21.3** — orderly shutdown (init supervises its children on one + endpoint that also carries signals, power events, and a re-arming + heartbeat timer; on `power_button` or a `terminate` signal it logs + `init: shutting down`, runs `stop(child, 2000, endpoint)` in reverse + order, then requests `.power` shutdown; the acpi service honors shutdown + from a subscriber — init is the one subscriber, a soft gate that survives + testing where PID 1 isn't init — and writes SLP_TYP|SLP_EN from ring 3. + `orderly-shutdown` scenario proves button → shutting-down → S5 → QEMU + exit; suite 60/60). - [ ] **merge** `feat/power-events` → main, push, keep the branch — **loop ends here**. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 9154710..eaf9146 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -154,6 +154,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { acpiReportTest(boot_information); // same spawn; the harness regex differs } else if (eql(case, "power-button")) { acpiReportTest(boot_information); // boot the manager (spawns the acpi service); harness injects the button + } else if (eql(case, "orderly-shutdown")) { + orderlyShutdownTest(boot_information); } else if (eql(case, "initial-ramdisk")) { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { @@ -1926,6 +1928,27 @@ fn pciScanTest(boot_information: *const BootInformation) void { result(); } +/// M21.3 capstone: orderly shutdown. Boot init with the initial-ramdisk +/// published, so init spawns the full service tree (vfs, input, device-manager +/// -> discovery/acpi); the harness injects a real power-button event via QMP; +/// the acpi service publishes it; init runs the stop sequence over its children +/// and asks the power service for S5; the machine powers off (QEMU exits). The +/// kernel test only spawns init — the ordered chain is the harness assertion. +fn orderlyShutdownTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: orderly-shutdown\n", .{}); + if (boot_information.init_len == 0 or boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over init and the initial_ramdisk", false); + result(); + return; + } + const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + process.setInitialRamdisk(ramdisk); + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len]; + const spawned = if (process.spawnProcess(image, 4, &.{"/system/services/init"})) true else |_| false; + check("init spawned as PID root of user space", spawned); + result(); +} + /// M20.2: the acpi service registers + reports its _HID devices. Boot normally /// (the manager spawns discovery); the harness's expect regex requires the two /// PS/2 nodes among the service's report lines, each with its _CRS resources — diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index df8542c..77da973 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -59,9 +59,14 @@ const sci_en_bit: u32 = 1 << 0; const slp_en: u32 = 1 << 13; // The `.power` subscribers: endpoints handed over as capabilities, each -// receiving events as buffered messages. Dropped on a failed send. +// receiving events as buffered messages. Dropped on a failed send. The +// subscriber's task id is kept too — a shutdown request is honored only from a +// subscriber (init subscribes; a stray process does not), the soft gate that +// stands in for "only the system supervisor may power off" without hardcoding +// a pid the kernel's idle tasks would have taken. const maximum_subscribers = 8; var subscribers: [maximum_subscribers]?runtime.ipc.Handle = .{null} ** maximum_subscribers; +var subscriber_tasks: [maximum_subscribers]u32 = .{0} ** maximum_subscribers; // Pass-1 registration record (see main): what pass 2 reports. const Registered = struct { hid: [8]u8 = .{0} ** 8, hid_len: usize = 0, device_id: u64 = 0, resource_count: u64 = 0 }; @@ -390,6 +395,13 @@ fn publishEvent(bytes: []const u8) void { } } +fn isSubscriber(task: u32) bool { + for (&subscribers, 0..) |*slot, si| { + if (slot.* != null and subscriber_tasks[si] == task) return true; + } + return false; +} + /// Enter S5 (soft off): write SLP_TYP|SLP_EN to the PM1 control register(s). /// Mirrors the kernel's power.zig sleepValue. Only reached from a PID-1 /// shutdown request (M21.3). @@ -423,9 +435,10 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime @intFromEnum(power.Operation.subscribe) => { var status: i32 = -1; if (capability) |handle| { - for (&subscribers) |*slot| { + for (&subscribers, 0..) |*slot, si| { if (slot.* == null) { slot.* = handle; + subscriber_tasks[si] = sender; status = 0; break; } @@ -436,11 +449,14 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?runtime return @sizeOf(power.Reply); }, @intFromEnum(power.Operation.shutdown) => { - // Only PID 1 (init), which has already stopped everything else. - const status: i32 = if (sender == 1) 0 else -1; - const r = power.Reply{ .status = status }; + // Honored only from a power subscriber — init, which has already run + // the stop sequence over everything else. The power service is + // mechanism (write S5); deciding *when* to shut down and stopping + // the rest of the system first is init's policy. + const allowed = isSubscriber(sender); + const r = power.Reply{ .status = if (allowed) 0 else -1 }; @memcpy(reply[0..@sizeOf(power.Reply)], std.mem.asBytes(&r)); - if (sender == 1) enterS5(); + if (allowed) enterS5(); return @sizeOf(power.Reply); }, else => return 0, diff --git a/system/services/init/init.zig b/system/services/init/init.zig index 461a2b4..dc23354 100644 --- a/system/services/init/init.zig +++ b/system/services/init/init.zig @@ -6,12 +6,20 @@ //! //! It proves the C-convention heap works, then — as PID 1 — acts as the system's //! **service supervisor**: it spawns the user-space services danos brings up at boot -//! (the VFS server, the device manager), and settles into a heartbeat so it stays -//! alive as the root of user space. Drivers are *not* its job: the device manager -//! discovers the hardware and spawns those. This is the service half of the -//! service/driver spawn split (docs/driver-model.md). +//! (the VFS server, the device manager), and settles into an event loop as the root +//! of user space. Drivers are *not* its job: the device manager discovers the +//! hardware and spawns those. This is the service half of the service/driver spawn +//! split (docs/driver-model.md). +//! +//! M21: init also owns **orderly shutdown**. It supervises its children (keeping +//! their ids and an exit endpoint), subscribes to the power service, and on a +//! power-button event runs the stop sequence over its children in reverse order +//! before asking the power service to enter S5 — lifecycle (M17) and events (M21) +//! composing into a clean poweroff. +const std = @import("std"); const runtime = @import("runtime"); +const power = runtime.power_protocol; /// The system services init brings up at boot, in order. This is init's policy — the /// microkernel keeps such choices in user space, not the kernel. Drivers are absent @@ -19,6 +27,10 @@ const runtime = @import("runtime"); /// manifest under /system/services instead of a hardcoded list.) const boot_services = [_][]const u8{ "vfs", "input", "device-manager" }; +var children: [boot_services.len]u32 = .{0} ** boot_services.len; +var child_count: usize = 0; +var supervision_endpoint: runtime.ipc.Handle = 0; + pub fn main() void { // Prove the heap end to end: allocate through the runtime allocator (which // mmaps pages from the kernel and carves them with the free list), write into @@ -34,19 +46,94 @@ pub fn main() void { gpa.free(buffer); } else |_| {} - // Bring up the boot services. Best-effort and silent: each service announces its - // own readiness (`vfs: ready`, ...), and in an isolation test that runs init with - // no initial-ramdisk the spawns simply no-op rather than deranging the heartbeat. + // One endpoint carries everything init waits on: children's exit + // notifications (they are spawned supervised against it), init's own + // signals, and power events it subscribes to. All arrive in the loop below. + supervision_endpoint = runtime.ipc.createIpcEndpoint() orelse { + _ = runtime.system.write("init: no endpoint\n"); + return; + }; + _ = runtime.process.bindSignals(supervision_endpoint); + + // Bring up the boot services, supervised so init can stop them cleanly. + // Best-effort and silent: each service announces its own readiness, and in + // an isolation test with no initial-ramdisk the spawns simply no-op. for (boot_services) |service| { - _ = runtime.system.spawn(service); + if (runtime.system.spawnSupervised(service, &.{}, supervision_endpoint)) |id| { + children[child_count] = id; + child_count += 1; + } } + // Subscribe to power events (retry: the power service registers well after + // init starts). Best-effort — without it, a `terminate` signal still + // triggers the same shutdown path. + subscribePower(); + + // A re-arming timer drives the liveness heartbeat: proof PID 1 is alive + // (the init test's marker) while the loop stays free to receive signals, + // power events, and children's exit notifications. + _ = runtime.system.timerOnce(supervision_endpoint, 1000); + + var receive: [power.message_maximum]u8 = undefined; while (true) { - _ = runtime.system.write("init: heartbeat\n"); - runtime.system.sleep(1000); + const got = runtime.ipc.replyWait(supervision_endpoint, &.{}, &receive, null); + if (runtime.process.signalsFrom(got.badge)) |signals| { + if (signals.has(.terminate)) shutDown(); + continue; + } + if (got.isTimer()) { + _ = runtime.system.write("init: heartbeat\n"); + _ = runtime.system.timerOnce(supervision_endpoint, 1000); + continue; + } + if (got.isMessage() and got.len >= 2 and receive[0] == @intFromEnum(power.Operation.event)) { + // A power event (the only buffered messages init receives). + if (receive[1] == @intFromEnum(power.Event.power_button)) shutDown(); + continue; + } + // Child-exit notifications and anything else: keep waiting. + if (got.isNotification()) continue; } } +/// Look up the power service and subscribe our endpoint (handed over as the +/// call's capability) so events arrive as buffered messages here. +fn subscribePower() void { + var handle: ?runtime.ipc.Handle = null; + var tries: u32 = 0; + while (handle == null and tries < 200) : (tries += 1) { + handle = runtime.ipc.lookup(.power); + if (handle == null) runtime.system.sleep(20); + } + // A missing power service is not fatal — init proceeds to its heartbeat and + // a `terminate` signal still drives shutdown. Silent so the no-ramdisk init + // test's heartbeat marker is the next line written. + const h = handle orelse return; + const request = power.Subscribe{}; + var reply: [power.message_maximum]u8 = undefined; + _ = runtime.ipc.callCap(h, std.mem.asBytes(&request), &reply, supervision_endpoint) catch {}; +} + +/// The stop sequence: terminate each child in reverse spawn order (vfs last — +/// other services may flush through it), waiting up to a deadline for each to +/// exit before killing it, then ask the power service to enter S5. +fn shutDown() void { + _ = runtime.system.write("init: shutting down\n"); + var i = child_count; + while (i > 0) { + i -= 1; + if (children[i] != 0) runtime.process.stop(children[i], 2000, supervision_endpoint); + } + if (runtime.ipc.lookup(.power)) |h| { + const request = power.Shutdown{}; + var reply: [power.message_maximum]u8 = undefined; + _ = runtime.ipc.call(h, std.mem.asBytes(&request), &reply) catch {}; + } + // If S5 did not take, init has nothing left to do but idle. + while (true) runtime.system.sleep(1000); +} + pub const panic = runtime.panic; comptime { _ = &runtime.start._start; // pull the runtime entry shim into the image diff --git a/test/qemu_test.py b/test/qemu_test.py index 0248027..c77cb22 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -301,6 +301,19 @@ CASES = [ "qmp_after": {"delay": 4, "command": "system_powerdown"}, "expect": r"power: button pressed", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # M21.3 capstone: orderly shutdown. Boot init (the full tree comes up); + # ~5s in, QMP system_powerdown raises the power button; the acpi service + # publishes it, init stops its children then requests S5, and QEMU exits. + # The ordered regex proves button -> shutting-down -> entering-S5; the case + # passes on QEMU's self-exit through S5 (docs/m21-plan.md). + {"name": "orderly-shutdown", + "smp": 4, + "timeout": 90, + "qmp_after": {"delay": 5, "command": "system_powerdown"}, + "expect": r"power: button pressed[\s\S]*" + r"init: shutting down[\s\S]*" + r"power: entering S5", + "fail": r"power: S5 write did not take|DANOS-TEST-RESULT: FAIL"}, # M20.2: the acpi service evaluates _CRS/_STA in ring 3 and registers + # reports its _HID devices — the two PS/2 nodes must appear with resources # (keyboard: io 0x60/0x64 + IRQ = 3; mouse: IRQ = 1) (docs/m19-m20-plan.md). From 446f655c696c7e1bc2d2b890b0b33f36553c2db6 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:57:17 +0100 Subject: [PATCH 5/5] Docs: close the M21 track (events + system power) docs/m19-m20-plan.md's M21 preview now points at the completed plan. --- docs/m19-m20-plan.md | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/docs/m19-m20-plan.md b/docs/m19-m20-plan.md index b06dee2..117aa5a 100644 --- a/docs/m19-m20-plan.md +++ b/docs/m19-m20-plan.md @@ -189,24 +189,8 @@ suspend/resume — a future *lifecycle-vocabulary* extension, since "suspend" has the shape of a signal every driver must answer, and it has no consumer until laptop sleep); CPU P/C-states. -## M21 preview — ACPI events + system power (planned next, not in this loop) +## M21 — ACPI events + system power — DONE -The acpi service grows the event side (settled direction 2026-07-13; detailed -phases when M20 lands): - -- **21.1 SCI + fixed events**: irq_bind the SCI (the resource M20.1 already - records), read/clear PM1 status, publish the power-button event to - subscribers (the same pub/sub shape the manager uses). -- **21.2 GPE + Notify**: Notify dispatch in the shared AML interpreter, GPE - block handling, `Notify(device, code)` published per reported node. The - acpi service is a **bus** here: battery (PNP0C0A), AC (ACPI0003), and lid - (PNP0C0D) nodes are reported children; small class drivers bind them and - speak an evaluate/subscribe protocol to the service — the xHCI split, - repeated. The embedded controller (`_Qxx` queries) rides this phase; - QEMU emulates no battery/EC, so those paths are interface-complete and - validated on real hardware (the laptop is the win condition), while the - plumbing is proven by the power button. -- **21.3 the capstone**: QEMU `system_powerdown` → acpi service event → init - runs the M17 stop sequence over its children → kernel `\_S5` — orderly - shutdown as the scenario that proves lifecycle + events compose. (The - harness grows a QMP poke to inject the event.) +Built and merged (docs/m21-plan.md, 2026-07-13): the SCI + power button, Notify/GPE +dispatch, and orderly shutdown (init's stop cascade into a ring-3 S5 write). +See that plan for the phase record.