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

60 lines
2.3 KiB
Python

"""list_vms / vm_info status reporting."""
from fastmcp import Client
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.server import mcp
from test_lifecycle import seeded_record
async def test_list_vms_stopped(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
result = result_data(await client.call_tool("list_vms", {}))
assert result["vms"][0]["status"] == "stopped"
assert result["registry_warnings"] == []
async def test_list_vms_running(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.responses["query-status"] = {"status": "running"}
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
result = result_data(await client.call_tool("list_vms", {}))
assert result["vms"][0]["status"] == "running"
async def test_list_vms_unreachable_when_qmp_fails(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.connect_error = OSError("connection refused")
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
result = result_data(await client.call_tool("list_vms", {}))
assert result["vms"][0]["status"] == "unreachable"
async def test_vm_info_running_includes_block_devices(dirs, fake_qmp, all_pids_alive):
FakeQMPClient.responses["query-status"] = {"status": "running"}
FakeQMPClient.responses["query-cpus-fast"] = [{"cpu-index": 0}, {"cpu-index": 1}]
FakeQMPClient.responses["query-block"] = [
{
"device": "virtio0",
"inserted": {"file": "/vms/a.qcow2", "drv": "qcow2", "ro": False},
},
{"device": "empty-cd"},
]
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
info = result_data(await client.call_tool("vm_info", {"name": "vm1"}))
assert info["status"] == "running"
assert info["vcpus"] == 2
assert info["block_devices"] == [
{"device": "virtio0", "file": "/vms/a.qcow2", "format": "qcow2", "read_only": False}
]
async def test_vm_info_stopped_skips_qmp(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
info = result_data(await client.call_tool("vm_info", {"name": "vm1"}))
assert info["status"] == "stopped"
assert "vcpus" not in info