mcqemu/tests/conftest.py
Ryan Malloy f053762c4e Harden failure paths found in the reliability review
The success paths were fine; several failure paths drew a confident
conclusion without checking the thing they waited for.

Registry (was: any parse error killed the whole server, since load() runs
in the lifespan):
- quarantine an unreadable file and start empty instead of raising, so the
  tools that stop runaway VMs keep working when bookkeeping is damaged
- skip malformed or invalidly-named records rather than failing the load;
  report both through list_vms as registry_warnings
- read-modify-write under an exclusive flock so a second instance merges
  instead of clobbering, with a PID-unique temp file
- drop the lifespan shutdown save, which could resurrect deleted records
- version the schema and round-trip unknown record fields

sandbox_destroy (the only tool that deletes files):
- verify the process actually died, escalating to SIGKILL, and refuse to
  delete an overlay QEMU still holds open
- assert the target is inside the VM state tree before rmtree
- report cleanup errors instead of swallowing them; destroyed now reflects
  what happened

Launch races:
- reserve the name before the first await so two concurrent launches cannot
  race over one set of sockets
- refuse to unlink a QMP socket that is still accepting connections
- register the VM with a warning rather than orphaning it when the pidfile
  is unreadable but QEMU is up

Guest agent and QMP:
- bound every guest-agent call, not just the handshake; cap max_bytes and
  stop guest_file_read spinning on a zero-progress agent
- serialize QMP sessions per VM (the monitor is single-client) and say
  "another operation holds it" instead of "the VM has likely exited"
- poll liveness while waiting for SHUTDOWN so a crashed VM is reported as
  exited rather than as a guest ignoring ACPI
- default command timeout, with a longer bound for savevm/loadvm
- stricter snapshot tags; log destructive operations to stderr

Adds tests/test_reliability.py covering the conditions above.
2026-08-17 15:53:09 -06:00

124 lines
3.4 KiB
Python

"""Shared fixtures: temp XDG dirs, scriptable fake QMP client, in-memory MCP client."""
import asyncio
import inspect
import json
from types import SimpleNamespace
import pytest
from fastmcp import Client
from mcqemu.server import mcp
@pytest.fixture
def dirs(tmp_path, monkeypatch):
state = tmp_path / "state"
run = tmp_path / "run"
state.mkdir()
run.mkdir()
monkeypatch.setenv("MCQEMU_STATE_DIR", str(state))
monkeypatch.setenv("MCQEMU_RUNTIME_DIR", str(run))
return SimpleNamespace(state=state, run=run)
def write_registry(dirs, *records) -> None:
"""Pre-seed the registry file before a Client connects (lifespan loads it)."""
(dirs.state / "vms.json").write_text(json.dumps({r.name: r.to_dict() for r in records}))
@pytest.fixture
async def client(dirs):
async with Client(mcp) as c:
yield c
def result_data(result):
# Empty-list results carry no text content block; .data covers both shapes.
if result.data is not None:
return result.data
return json.loads(result.content[0].text)
class FakeQMPClient:
"""Stands in for qemu.qmp.QMPClient. Script it via class attributes:
responses: dict command -> canned reply, Exception to raise, or callable(args)
events_to_emit: list of event dicts fed to the events queue
connect_error: exception raised from connect()
calls: recorded (command, arguments) tuples
"""
responses: dict = {}
events_to_emit: list = []
connect_error: Exception | None = None
calls: list = []
@classmethod
def reset(cls):
cls.responses = {}
cls.events_to_emit = []
cls.connect_error = None
cls.calls = []
def __init__(self, name: str):
self.name = name
self.await_greeting = True
self.negotiate = True
self._events: asyncio.Queue = asyncio.Queue()
async def connect(self, address):
if FakeQMPClient.connect_error is not None:
raise FakeQMPClient.connect_error
for event in FakeQMPClient.events_to_emit:
self._events.put_nowait(event)
async def disconnect(self):
pass
async def execute(self, command, arguments=None):
FakeQMPClient.calls.append((command, arguments))
reply = FakeQMPClient.responses.get(command, {})
if isinstance(reply, Exception):
raise reply
if callable(reply):
reply = reply(arguments)
# Awaitable replies let a test simulate an agent that stops answering.
if inspect.isawaitable(reply):
reply = await reply
return reply
@property
def events(self):
return self._events
@pytest.fixture
def fake_qmp(monkeypatch):
FakeQMPClient.reset()
monkeypatch.setattr("mcqemu.qmp.QMPClient", FakeQMPClient)
monkeypatch.setattr("mcqemu.qga.QMPClient", FakeQMPClient)
return FakeQMPClient
# Every module that imported pid_alive into its own namespace needs patching,
# or a "dead" fixture leaves one module still seeing live processes.
_PID_ALIVE_REFS = (
"mcqemu.registry.pid_alive",
"mcqemu.qmp.pid_alive",
"mcqemu.tools.lifecycle.pid_alive",
"mcqemu.tools.sandbox.pid_alive",
)
@pytest.fixture
def all_pids_dead(monkeypatch):
for ref in _PID_ALIVE_REFS:
monkeypatch.setattr(ref, lambda pid: False)
@pytest.fixture
def all_pids_alive(monkeypatch):
for ref in _PID_ALIVE_REFS:
monkeypatch.setattr(ref, lambda pid: bool(pid))