feat: add independent VM manager

This commit is contained in:
Codex
2026-10-13 04:24:38 +03:00
parent 4ce155ea6e
commit 7676389945
9 changed files with 1020 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import json
import socket
import tempfile
import threading
import time
import unittest
from pathlib import Path
from hydra.qmp import QMPClient, QMPError
class QMPServer:
def __init__(self, path, handler):
self.socket = socket.socket(socket.AF_UNIX)
self.socket.bind(str(path))
self.socket.listen(4)
self.socket.settimeout(0.1)
self.handler = handler
self.stop = threading.Event()
self.errors = []
self.thread = threading.Thread(target=self.run, daemon=True)
self.thread.start()
def run(self):
while not self.stop.is_set():
try:
conn, _ = self.socket.accept()
except socket.timeout:
continue
except OSError:
break
try:
with conn:
conn.settimeout(1)
self.handler(conn)
except (BrokenPipeError, ConnectionResetError, socket.timeout):
pass
except Exception as exc:
self.errors.append(exc)
def close(self):
self.stop.set()
self.socket.close()
self.thread.join(2)
def send(conn, message):
conn.sendall(json.dumps(message).encode() + b'\r\n')
def greeting(conn):
send(conn, {'QMP': {'version': {'qemu': {'major': 10, 'minor': 0, 'micro': 0},
'package': ''}, 'capabilities': []}})
class QMPTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='hq-')
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / 'qmp'
def server(self, handler):
srv = QMPServer(self.path, handler)
self.addCleanup(srv.close)
return srv
def test_handshake_ids_events_and_return(self):
commands = []
def handler(conn):
greeting(conn)
stream = conn.makefile('rb')
for index in range(2):
request = json.loads(stream.readline())
commands.append(request)
send(conn, {'event': 'STOP', 'data': {}})
send(conn, {'return': {'ignored': True}, 'id': 'unrelated'})
send(conn, {'return': {} if index == 0 else {'status': 'running'}, 'id': request['id']})
self.server(handler)
with QMPClient(self.path, timeout=0.5) as client:
self.assertEqual(client.command('query-status'), {'status': 'running'})
self.assertEqual([cmd['execute'] for cmd in commands], ['qmp_capabilities', 'query-status'])
self.assertNotEqual(commands[0]['id'], commands[1]['id'])
def test_command_error(self):
def handler(conn):
greeting(conn)
stream = conn.makefile('rb')
for index in range(2):
request = json.loads(stream.readline())
if index == 0:
send(conn, {'return': {}, 'id': request['id']})
else:
send(conn, {'error': {'class': 'CommandNotFound', 'desc': 'unsupported'}, 'id': request['id']})
self.server(handler)
with QMPClient(self.path, timeout=0.5) as client, self.assertRaises(QMPError):
client.command('missing')
def test_greeting_timeout_is_bounded(self):
self.server(lambda conn: time.sleep(0.3))
started = time.monotonic()
with self.assertRaises(QMPError):
QMPClient(self.path, timeout=0.08)
self.assertLess(time.monotonic() - started, 0.25)
def test_event_stream_cannot_extend_command_deadline(self):
def handler(conn):
greeting(conn)
request = json.loads(conn.makefile('rb').readline())
while True:
send(conn, {'event': 'STOP', 'data': {}})
time.sleep(0.01)
self.server(handler)
started = time.monotonic()
with self.assertRaises(QMPError):
QMPClient(self.path, timeout=0.08)
self.assertLess(time.monotonic() - started, 0.25)
def test_malformed_greeting_rejected(self):
self.server(lambda conn: send(conn, {'return': {}}))
with self.assertRaises(QMPError):
QMPClient(self.path)
def test_command_timeout_after_successful_handshake(self):
def handler(conn):
greeting(conn)
with conn.makefile('rb') as stream:
request = json.loads(stream.readline())
send(conn, {'return': {}, 'id': request['id']})
stream.readline()
time.sleep(0.3)
self.server(handler)
with QMPClient(self.path, timeout=0.08) as client:
started = time.monotonic()
with self.assertRaises(QMPError):
client.command('query-status')
self.assertLess(time.monotonic() - started, 0.25)
if __name__ == '__main__':
unittest.main()