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.
124 lines
3.4 KiB
Python
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))
|