7.8 KiB
M21 execution plan: ACPI events + system power
The operational plan for the event side of the acpi service and orderly shutdown — the capstone m19-m20-plan.md previewed. Same rules as its predecessors: one phase at a time, each green before the next; this file is the build order and the checklist.
Definition of green, every phase: zig build clean, zig build test
clean, python3 test/qemu_test.py passes (existing scenarios plus the
phase's new one), and the relevant design doc updated. Commit per green phase
(no co-author trailers). Failing cases preserve their serial logs
(<case>-failed-serial.log).
Workflow: dedicated worktree; branch feat/power-events off main;
auto-merge to main when the branch is green; keep the branch; push everything.
Settled decisions (2026-07-13, approved)
- S5 is executed by the acpi service from ring 3. No new syscall: the
broad port grant (M20 decision 5) already made this physically possible —
the service holds the PM1 control ports in its io grant and derives
_S5from its own namespace (aml.sleepState). Formalizing it adds no authority. The kernel keepspower.zigfor its own test paths and panic-time use. - The power surface is domain-named (decision 7 of the last plan): a
power-protocolmodule +ServiceId.power = 5, registered by the acpi service — on ARM, a PSCI/mailbox service registers the same id and subscribers never know the difference. Messages:subscribe(endpoint as the call's capability, the input/manager pattern),shutdown(accepted only from PID 1 — init), and events published as buffered messages:power_button,lid,ac,battery, genericnotifywith a code. - The service learns event ports from its own FADT copy: the kernel adds the FADT as one more memory resource on the acpi-tables node; the service tells it apart from the AML blobs by signature ("FACP" header — the blob resources are header-stripped bytecode and start with no signature). The kernel's own FADT parse is untouched.
- The acpi service converts to the harness (
runtime.service.run): protocol messages (subscribe/shutdown), the SCI notification, and the existing report flow fold into one loop — the shape it was always meant to have. - GPE/Notify correctness is proven by host unit tests (synthetic AML
with a Notify inside a method body; aml.zig joins the
zig build testloop). The QEMU scenario proves the power button — a fixed event, deterministically injectable via QMPsystem_powerdown— because QEMU cannot raise GPEs deterministically on this config. Battery/AC/lid and the embedded controller (_Qxx) are interface-complete here and validated on real hardware (the laptop) later.
Ground truth the phases build on (verified 2026-07-13)
system/devices/power.zigshutdown()is the kernel's S5 write (SLP_TYP|SLP_EN to PM1a/PM1b control); there is no power syscall.- init (
system/services/init/init.zig) spawns vfs/input/device-manager fire-and-forget — no child ids kept, no signals, no event loop. The whole stop toolkit exists inruntime.process(stop/sendSignal/bindSignals). test/qemu_test.pyhas no QMP channel (serial is a one-way file).- The kernel parses PM1 control blocks and SCI_INT from the FADT; the PM1 event blocks (offsets 56/60, len at 88) and GPE0/GPE1 blocks (offsets 80/84, lens 92/93) are unparsed — the service reads them from its FADT copy (decision 3).
- The acpi-tables node carries the SCI as its only
len == 1irq resource (the broad window is len 256) — that is how the service finds it toirqBind. notify_opcode = 0x86exists insystem/devices/aml/opcodes.zigbut the interpreter never handles it — a GPE_Lxxbody containing Notify fails evaluation today. Everything else a GPE handler needs (field access, control flow, method calls) is proven by the ring-3_STA/_CRSwork.- The dead-code sweep (spawned task) also edits
system/devices/acpi.zig; M21.0 checks whether it landed and rebases before touching that file.
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 theqmp_capabilitieshandshake, a per-caseqmp_afterhook that sends a command N seconds after boot); existing suite stays green. - M21.1 — SCI + the power button: kernel appends the FADT as an
acpi-tables memory resource; new
power-protocolmodule +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 publishespower_button(log:power: button pressed), always irqAck. Scenariopower-button:qmp_after system_powerdown→ expect the log line. - M21.2 — Notify + GPE dispatch: interpreter handles
notify_opcodeinto 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_Qxxexplicitly out (hardware track). Host unit tests for Notify in aml.zig; aml.zig joins thezig build testloop. - M21.3 — orderly shutdown: init keeps child ids (spawnSupervised +
exit endpoint), binds signals, subscribes to
.power; onpower_buttonlogsinit: shutting down, runsstop(child, 2000, endpoint)in reverse spawn order, then sendsshutdownto.power; the acpi service (sender PID 1 only) logspower: entering S5and writes SLP_TYP|SLP_EN from ring 3. Scenarioorderly-shutdown: boot via init,qmp_after system_powerdown, ordered regex button→shutting-down→entering-S5, pass on QEMU exit. Docs + memory updated. - merge
feat/power-events→ main, push, keep the branch — loop ends here.
Phase notes
M21.0 QMP: open the unix socket after Popen, complete the
qmp_capabilities handshake, then send the hook's command (for these
scenarios: {"execute": "system_powerdown"}). The socket is additive — no
existing case may notice it. Note e3fe3f3 recently reworked how the harness
boots; adapt to its current shape rather than the pre-rework description.
M21.1 SCI details: PM1_STS is at the event block base (write-1-to-clear); PM1_EN at base + block_len/2; PWRBTN bit is 8 in both. If PM1b exists, mirror reads/writes to both blocks. Enable ACPI mode only when SCI_EN (PM1 control bit 0) is clear — OVMF boots may already have it set. The publish path reuses the manager's subscriber table pattern (bounded, drop-on-failed-send).
M21.2 GPE walk: GPE0_STS bytes live at the GPE0 block base, GPE0_EN in
the block's upper half; for a set+enabled bit n, the handler method is
_L%02X (level) or _E%02X (edge) under \_GPE. Evaluate, drain the notify
queue, clear the status bit, ack. A missing handler method is clear-and-log,
not an error.
M21.3 ordering: init subscribes with retries — the acpi service registers
.power well after init starts. The stop sequence runs vfs last (other
services may flush through it). The S5 write mirrors power.zig's
sleepValue (SLP_TYP bits [12:10], SLP_EN bit 13); if the write returns, log
power: S5 write did not take so the scenario fails loudly instead of
hanging.
Explicitly out of scope: the embedded controller and _Qxx queries,
battery _BST/_BIF evaluation beyond the interface stubs, lid/AC on QEMU
(no emulation), reboot over the power protocol, S3 sleep, per-device D-states
(a future lifecycle-vocabulary extension), thermal zones.