usb: hot-plug plumbing, all ports powered, interrupter enabled (M20)

B3 — the runtime lifecycle a hot-pluggable bus needs, plus two init
fixes that runtime device arrival depends on:

- pump() now handles PORT STATUS CHANGE events (silently dropped
  before): it queues the port, and the bus driver brings the port up
  (a device arrived) or tears it down (a device left) on its tick —
  reporting each interface ChildRemoved to the device manager, which
  prunes the node, notifies watchers, and lets the class driver's world
  end honestly, then Disable Slot frees the controller-side state.
- ALL root-hub ports are powered at init, not just those with a
  boot-time device: an unpowered port (PP=0) cannot signal a later
  connect, so a hot-plug would never be seen.
- the interrupter is enabled (IMAN.IE + USBCMD.INTE) while the ring
  stays polled — some controllers only WRITE runtime events to the
  ring when the interrupter is enabled.

Real-hardware validation is flagged for the user: QEMU's qemu-xhci does
not raise a runtime port-change event to a polling driver on device_add,
so the end-to-end hot-plug path can't be exercised in the harness (the
port-change handling itself IS proven — a late boot device's PSCE is
caught and acked). The harness gained qmp_sequence (multi-step QMP
injection with arguments) for when a drivable case exists. Full suite
88/88; the working USB path (enumeration, HID, storage) is unregressed
by the port-power and interrupter changes.
This commit is contained in:
Daniel Samson
2026-07-21 21:01:41 +01:00
parent 4091ea6912
commit 0ac07dadc9
3 changed files with 180 additions and 47 deletions
+26 -14
View File
@@ -625,6 +625,11 @@ CASES = [
"fail": r"DANOS-TEST-RESULT: FAIL"},
# The user-space VFS: a client opens/writes/reads a file through the rt file
# API, which IPCs the VFS server process; the round trip must match.
# (A usb-hotplug case was prototyped here, but QEMU's qemu-xhci does not
# raise a runtime port-change event to a polling driver on device_add, so it
# cannot exercise the path. The hot-plug code — port-change queue, teardown
# via Disable Slot, ChildRemoved reporting — is validated on real hardware,
# flagged for the user. The qmp_sequence harness support it added remains.)
# The kernel VFS root (M-F): the mount table serves the initrd at /system —
# path resolution, node status/read (an ELF magic), and directory listing,
# asserted kernel-side.
@@ -704,11 +709,12 @@ def resolve_firmware(arch):
+ "\nInstall OVMF (edk2-ovmf / ovmf) or add its path above.")
def qmp_send(path, command):
def qmp_send(path, command, arguments=None):
"""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/power.md)."""
a case injects a host-side event into the running guest: system_powerdown
(the ACPI power button, docs/power.md) or device_add/device_del (USB
hot-plug, docs/driver-model.md)."""
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
try:
@@ -718,7 +724,10 @@ def qmp_send(path, command):
stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n")
stream.flush()
stream.readline() # {"return": {}}
stream.write(json.dumps({"execute": command}) + "\n")
message = {"execute": command}
if arguments:
message["arguments"] = arguments
stream.write(json.dumps(message) + "\n")
stream.flush()
stream.readline()
finally:
@@ -767,8 +776,10 @@ def run_case(arch, case):
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
# Hooks: a single qmp_after {"delay","command"} or a qmp_sequence list of
# {"delay","command","arguments"} — every hook must deliver before a pass.
qmp_hooks = case.get("qmp_sequence") or ([case["qmp_after"]] if case.get("qmp_after") else [])
qmp_pending = [dict(hook, sent=False) for hook in qmp_hooks]
started = time.monotonic()
qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
@@ -776,12 +787,13 @@ def run_case(arch, case):
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
for hook in qmp_pending:
if not hook["sent"] and time.monotonic() - started >= hook["delay"]:
try:
qmp_send(qmp_path, hook["command"], hook.get("arguments"))
hook["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:
@@ -789,8 +801,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
if any(not hook["sent"] for hook in qmp_pending):
continue # every 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):