feat: add independent VM manager
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Bounded JSON QMP over private Unix sockets, with reply ID matching."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class QMPError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class QMPClient:
|
||||
def __init__(self, path: Path, timeout: float = 2.0):
|
||||
if timeout <= 0:
|
||||
raise ValueError('QMP timeout must be positive')
|
||||
self.timeout = timeout
|
||||
self.socket = socket.socket(socket.AF_UNIX)
|
||||
self.buffer = bytearray()
|
||||
self.counter = 0
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
self.socket.settimeout(timeout)
|
||||
self.socket.connect(str(path))
|
||||
greeting = self._receive(deadline)
|
||||
if not isinstance(greeting.get('QMP'), dict):
|
||||
raise QMPError('Invalid QMP greeting')
|
||||
self._command('qmp_capabilities', None, deadline)
|
||||
except (OSError, QMPError) as exc:
|
||||
self.close()
|
||||
if isinstance(exc, QMPError):
|
||||
raise
|
||||
raise QMPError(f'QMP connection failed: {exc}') from exc
|
||||
|
||||
def _receive(self, deadline: float) -> dict:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise QMPError('QMP deadline exceeded')
|
||||
if b'\n' in self.buffer:
|
||||
line, _, rest = self.buffer.partition(b'\n')
|
||||
self.buffer = bytearray(rest)
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise QMPError('Malformed QMP JSON') from exc
|
||||
if not isinstance(message, dict):
|
||||
raise QMPError('QMP response must be an object')
|
||||
return message
|
||||
self.socket.settimeout(remaining)
|
||||
try:
|
||||
chunk = self.socket.recv(65536)
|
||||
except OSError as exc:
|
||||
raise QMPError(f'QMP receive failed: {exc}') from exc
|
||||
if not chunk:
|
||||
raise QMPError('QMP connection closed')
|
||||
self.buffer.extend(chunk)
|
||||
if len(self.buffer) > 1048576:
|
||||
raise QMPError('QMP message exceeds 1 MiB')
|
||||
|
||||
def _command(self, name: str, arguments: dict | None, deadline: float) -> dict:
|
||||
self.counter += 1
|
||||
request = {'execute': name, 'id': self.counter}
|
||||
if arguments is not None:
|
||||
request['arguments'] = arguments
|
||||
try:
|
||||
self.socket.settimeout(max(0.001, deadline - time.monotonic()))
|
||||
self.socket.sendall(json.dumps(request).encode() + b'\r\n')
|
||||
except OSError as exc:
|
||||
raise QMPError(f'QMP send failed: {exc}') from exc
|
||||
while True:
|
||||
response = self._receive(deadline)
|
||||
if response.get('id') != self.counter:
|
||||
continue
|
||||
if 'error' in response:
|
||||
raise QMPError(f'QMP {name}: {response["error"]}')
|
||||
if 'return' not in response:
|
||||
raise QMPError('QMP reply has no return value')
|
||||
return response['return']
|
||||
|
||||
def command(self, name: str, arguments: dict | None = None) -> dict:
|
||||
return self._command(name, arguments, time.monotonic() + self.timeout)
|
||||
|
||||
def close(self):
|
||||
self.socket.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
Reference in New Issue
Block a user