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

100 lines
3.9 KiB
Python

"""Live snapshot tools (HMP passthrough)."""
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from conftest import FakeQMPClient, result_data, write_registry
from mcqemu.server import mcp
from test_lifecycle import seeded_record
async def test_snapshot_create_success_on_silence(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = ""
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": "clean"})
)
assert data["created"] is True
assert ("human-monitor-command", {"command-line": "savevm clean"}) in FakeQMPClient.calls
async def test_snapshot_create_surfaces_hmp_error(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = (
"Error: Device 'virtio0' is writable but does not support snapshots"
)
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="does not support snapshots"):
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": "clean"})
async def test_snapshot_restore_and_delete(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = ""
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
assert result_data(
await client.call_tool("vm_snapshot_restore", {"name": "vm1", "tag": "clean"})
)["restored"]
assert result_data(
await client.call_tool("vm_snapshot_delete", {"name": "vm1", "tag": "clean"})
)["deleted"]
cmdlines = [a["command-line"] for _, a in FakeQMPClient.calls]
assert "loadvm clean" in cmdlines
assert "delvm clean" in cmdlines
async def test_snapshot_bad_tag_rejected(dirs, fake_qmp):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
for tag in ["has space", "-leading-dash", ""]:
with pytest.raises(ToolError):
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": tag})
async def test_snapshot_list_raw(dirs, fake_qmp):
FakeQMPClient.responses["human-monitor-command"] = (
"List of snapshots present on all disks:\n"
"ID TAG VM SIZE DATE\n-- clean 250M 2026-08-16"
)
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("vm_snapshot_list", {"name": "vm1"}))
assert "clean" in data["snapshots_raw"]
async def test_snapshot_tag_rejects_quotes_and_specials(dirs, fake_qmp):
"""HMP's tokenizer strips quotes, so 'x' and '\"x\"' would silently be the
same snapshot — reject anything that isn't literal."""
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
for tag in ['"quoted"', "semi;colon", "tab\tsep", "a" * 80]:
with pytest.raises(ToolError, match="Invalid snapshot tag"):
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": tag})
async def test_savevm_gets_a_long_but_finite_timeout(dirs, fake_qmp):
"""A RAM-sized savevm must not be killed by the ordinary command timeout."""
from mcqemu.qmp import COMMAND_TIMEOUT
from mcqemu.tools.snapshots import SNAPSHOT_TIMEOUT
assert SNAPSHOT_TIMEOUT > COMMAND_TIMEOUT * 10
seen = {}
async def spy(client, command, arguments=None, timeout=None):
seen["timeout"] = timeout
return ""
import mcqemu.tools.snapshots as snapshots_mod
original = snapshots_mod.execute
snapshots_mod.execute = spy
try:
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
await client.call_tool("vm_snapshot_create", {"name": "vm1", "tag": "clean"})
finally:
snapshots_mod.execute = original
assert seen["timeout"] == SNAPSHOT_TIMEOUT