mcqemu/tests/test_integration.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

73 lines
2.4 KiB
Python

"""Integration: boots a real (diskless) QEMU VM through the MCP tools.
Run with: uv run pytest -m integration
"""
import base64
import shutil
import pytest
from fastmcp import Client
from conftest import result_data
from mcqemu.registry import pid_alive
from mcqemu.server import mcp
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
shutil.which("qemu-system-x86_64") is None, reason="qemu-system-x86_64 not installed"
),
]
async def test_real_vm_lifecycle(dirs):
async with Client(mcp) as client:
data = result_data(
await client.call_tool(
"launch_vm",
{"name": "itest", "memory_mb": 128, "cpus": 1, "no_net": True},
)
)
pid = data["pid"]
try:
assert data["status"] == "running"
assert pid_alive(pid)
listing = result_data(await client.call_tool("list_vms", {}))
assert [(v["name"], v["status"]) for v in listing["vms"]] == [("itest", "running")]
info = result_data(await client.call_tool("vm_info", {"name": "itest"}))
assert info["vcpus"] == 1
data = result_data(await client.call_tool("pause_vm", {"name": "itest"}))
assert data["status"] == "paused"
listing = result_data(await client.call_tool("list_vms", {}))
assert listing["vms"][0]["status"] == "paused"
await client.call_tool("resume_vm", {"name": "itest"})
# See & drive against the live SeaBIOS display.
result = await client.call_tool("vm_screenshot", {"name": "itest"})
block = result.content[0]
assert block.type == "image"
png = base64.b64decode(block.data)
assert png[:8] == b"\x89PNG\r\n\x1a\n"
await client.call_tool(
"vm_send_keys", {"name": "itest", "keys": ["f2", "enter"], "delay_ms": 0}
)
await client.call_tool("vm_click", {"name": "itest", "x": 10, "y": 10})
data = result_data(await client.call_tool("vm_serial_read", {"name": "itest"}))
assert "tail" in data
data = result_data(await client.call_tool("stop_vm", {"name": "itest", "force": True}))
assert data["status"] == "stopped"
assert not pid_alive(pid)
finally:
if pid_alive(pid):
import os
import signal
os.kill(pid, signal.SIGKILL)