The harness grows a QMP channel (M21.0)

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.
This commit is contained in:
Daniel Samson
2026-07-13 05:22:53 +01:00
parent 738f6aa697
commit dfc7d6a609
2 changed files with 48 additions and 5 deletions
+5 -5
View File
@@ -70,11 +70,11 @@ auto-merge to main when the branch is green; keep the branch; push everything.
## Status ## Status
- [ ] **M21.0** — baseline: rebase over anything newly merged (the dead-code - [x] **M21.0** — baseline (dead-code sweep confirmed landed on main — no
sweep touches acpi.zig); cut `feat/power-events`; add the QMP channel to acpi.zig conflict; `feat/power-events` cut; QMP channel in the harness:
the harness (`-qmp unix:.../qmp.sock,server,nowait`, a small client with always-on unix socket, client with the capabilities handshake, per-case
the `qmp_capabilities` handshake, a per-case `qmp_after` hook that sends `qmp_after` hook, and a hook-must-deliver pass gate that the smoke case
a command N seconds after boot); existing suite stays green. now proves with a harmless query-status; suite 58/58).
- [ ] **M21.1** — SCI + the power button: kernel appends the FADT as an - [ ] **M21.1** — SCI + the power button: kernel appends the FADT as an
acpi-tables memory resource; new `power-protocol` module + acpi-tables memory resource; new `power-protocol` module +
`ServiceId.power`; the acpi service converts to the harness, registers `ServiceId.power`; the acpi service converts to the harness, registers
+43
View File
@@ -18,9 +18,11 @@ Usage:
""" """
import argparse import argparse
import json
import os import os
import re import re
import shutil import shutil
import socket
import subprocess import subprocess
import sys import sys
import time import time
@@ -80,7 +82,10 @@ ARCHES = {
# `expect`: a regex that must appear in serial output => pass. # `expect`: a regex that must appear in serial output => pass.
# `fail`: optional regex whose appearance => immediate fail. # `fail`: optional regex whose appearance => immediate fail.
CASES = [ 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", {"name": "smoke",
"qmp_after": {"delay": 2, "command": "query-status"},
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
{"name": "discovery", {"name": "discovery",
@@ -418,6 +423,27 @@ def resolve_firmware(arch):
+ "\nInstall OVMF (edk2-ovmf / ovmf) or add its path above.") + "\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): def run_case(arch, case):
err = build(arch, case["name"]) err = build(arch, case["name"])
if err: if err:
@@ -439,12 +465,27 @@ def run_case(arch, case):
cmd += ["-smp", str(case["smp"])] cmd += ["-smp", str(case["smp"])]
if case.get("qemu_extra"): # extra qemu args, e.g. -device intel-iommu for the IOMMU case if case.get("qemu_extra"): # extra qemu args, e.g. -device intel-iommu for the IOMMU case
cmd += case["qemu_extra"] 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) qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try: try:
timeout = case.get("timeout", TIMEOUT) timeout = case.get("timeout", TIMEOUT)
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
time.sleep(0.2) 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 = "" text = ""
if os.path.exists(serial): if os.path.exists(serial):
with open(serial, "r", errors="replace") as f: with open(serial, "r", errors="replace") as f:
@@ -452,6 +493,8 @@ def run_case(arch, case):
if fail and fail.search(text): if fail and fail.search(text):
return False, "hit failure marker" return False, "hit failure marker"
if expect.search(text): 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"]) return True, "matched " + repr(case["expect"])
if qemu.poll() is not None: # QEMU exited on its own if qemu.poll() is not None: # QEMU exited on its own
if expect.search(text): if expect.search(text):