danos/docs/m21-plan.md

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)

  1. 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 _S5 from its own namespace (aml.sleepState). Formalizing it adds no authority. The kernel keeps power.zig for its own test paths and panic-time use.
  2. The power surface is domain-named (decision 7 of the last plan): a power-protocol module + 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, generic notify with a code.
  3. 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.
  4. 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.
  5. GPE/Notify correctness is proven by host unit tests (synthetic AML with a Notify inside a method body; aml.zig joins the zig build test loop). The QEMU scenario proves the power button — a fixed event, deterministically injectable via QMP system_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.zig shutdown() 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 in runtime.process (stop/sendSignal/bindSignals).
  • test/qemu_test.py has 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 == 1 irq resource (the broad window is len 256) — that is how the service finds it to irqBind.
  • notify_opcode = 0x86 exists in system/devices/aml/opcodes.zig but the interpreter never handles it — a GPE _Lxx body containing Notify fails evaluation today. Everything else a GPE handler needs (field access, control flow, method calls) is proven by the ring-3 _STA/_CRS work.
  • 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 the qmp_capabilities handshake, a per-case qmp_after hook 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-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.
  • 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.
  • 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.
  • 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.