174 lines
7.0 KiB
Python
174 lines
7.0 KiB
Python
"""Владелец автономного launcher для MCP без запущенного VS Code."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import uuid
|
|
|
|
from .mcp_adapter import McpSession
|
|
from .session import SessionError
|
|
|
|
|
|
class SessionSupervisor:
|
|
def __init__(self, build: str, socket_path: str | None = None,
|
|
launcher_options: list[str] | None = None):
|
|
self.build = str(Path(build).expanduser().resolve())
|
|
self._temporary = None
|
|
if socket_path:
|
|
self.socket_path = str(Path(socket_path).expanduser())
|
|
else:
|
|
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
|
|
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
|
|
self.launcher_options = list(launcher_options or [])
|
|
self.lock = threading.RLock()
|
|
self.phase = 'idle'
|
|
self.error: str | None = None
|
|
self.entry = None
|
|
self.mame_pid = None
|
|
self.process: subprocess.Popen | None = None
|
|
self.monitor: threading.Thread | None = None
|
|
self.log = None
|
|
self.client: McpSession | None = None
|
|
self.launch_id = None
|
|
|
|
def status(self) -> dict:
|
|
with self.lock:
|
|
result = {'phase': self.phase, 'socket': self.socket_path,
|
|
'launch_id': self.launch_id}
|
|
if self.error:
|
|
result['error'] = self.error
|
|
if self.phase == 'ready' and self.client is not None:
|
|
try:
|
|
result.update(self.client.status())
|
|
result['mame_pid'] = self.mame_pid
|
|
result['entry'] = self.entry
|
|
except (SessionError, OSError) as error:
|
|
self.phase = 'failed'
|
|
self.error = str(error)
|
|
result.update(phase='failed', error=self.error)
|
|
return result
|
|
|
|
def require_client(self) -> McpSession:
|
|
with self.lock:
|
|
if self.phase != 'ready' or self.client is None:
|
|
raise SessionError('C-сессия ещё не готова: ' + self.phase)
|
|
return self.client
|
|
|
|
def start(self) -> dict:
|
|
with self.lock:
|
|
if self.phase in ('starting', 'ready', 'stopping'):
|
|
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
|
|
if self.process is not None and self.process.poll() is None:
|
|
raise SessionError('Предыдущий launcher ещё работает')
|
|
if self.monitor is not None and self.monitor.is_alive():
|
|
raise SessionError('Предыдущий launcher ещё завершает работу')
|
|
if not Path(self.build).is_dir():
|
|
raise SessionError('Debug-пакет не найден: ' + self.build)
|
|
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
|
|
'sdbg_launcher.py'), '--build', self.build,
|
|
'--socket', self.socket_path, *self.launcher_options]
|
|
if self.log is not None:
|
|
self.log.close()
|
|
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
|
|
self.phase = 'starting'
|
|
self.error = None
|
|
self.entry = None
|
|
self.mame_pid = None
|
|
self.client = None
|
|
self.launch_id = uuid.uuid4().hex
|
|
try:
|
|
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
|
stderr=self.log, text=True)
|
|
except OSError:
|
|
self.phase = 'failed'
|
|
raise
|
|
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
|
|
daemon=True)
|
|
self.monitor.start()
|
|
return {'accepted': True, 'phase': self.phase,
|
|
'socket': self.socket_path, 'launch_id': self.launch_id}
|
|
|
|
def _diagnostics(self) -> str:
|
|
if self.log is None:
|
|
return ''
|
|
self.log.flush()
|
|
self.log.seek(0)
|
|
return self.log.read()[-4000:].strip()
|
|
|
|
def _monitor(self) -> None:
|
|
process = self.process
|
|
assert process is not None and process.stdout is not None
|
|
try:
|
|
line = process.stdout.readline()
|
|
if not line:
|
|
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
|
|
ready = json.loads(line)
|
|
if not ready.get('ready') or ready.get('socket') != self.socket_path:
|
|
raise SessionError('Launcher вернул неверный ready')
|
|
client = McpSession(self.socket_path)
|
|
client.status()
|
|
with self.lock:
|
|
if self.phase == 'starting':
|
|
self.client = client
|
|
self.entry = ready['entry']
|
|
self.mame_pid = ready['pid']
|
|
self.phase = 'ready'
|
|
process.wait()
|
|
with self.lock:
|
|
if self.phase not in ('stopping', 'failed'):
|
|
self.phase = 'stopped'
|
|
self.error = 'Launcher завершился: ' + self._diagnostics()
|
|
elif self.phase == 'stopping':
|
|
self.phase = 'stopped'
|
|
self.client = None
|
|
except (OSError, ValueError, KeyError, SessionError) as error:
|
|
with self.lock:
|
|
if self.phase == 'stopping':
|
|
self.phase = 'stopped'
|
|
else:
|
|
self.phase = 'failed'
|
|
self.error = str(error)
|
|
self.client = None
|
|
|
|
def stop(self) -> dict:
|
|
with self.lock:
|
|
if self.phase not in ('starting', 'ready'):
|
|
return {'accepted': False, 'phase': self.phase}
|
|
self.phase = 'stopping'
|
|
if self.process is not None and self.process.poll() is None:
|
|
self.process.terminate()
|
|
return {'accepted': True, 'phase': 'stopping',
|
|
'socket': self.socket_path}
|
|
|
|
def close(self) -> None:
|
|
self.stop()
|
|
process = self.process
|
|
if process is not None:
|
|
try:
|
|
process.wait(timeout=8)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
if self.monitor is not None:
|
|
self.monitor.join(timeout=2)
|
|
if self.log is not None:
|
|
self.log.close()
|
|
if self._temporary is not None:
|
|
self._temporary.cleanup()
|
|
|
|
|
|
class ManagedMcpSession:
|
|
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
|
|
def __init__(self, supervisor: SessionSupervisor):
|
|
self.supervisor = supervisor
|
|
|
|
def status(self) -> dict:
|
|
return self.supervisor.status()
|
|
|
|
def __getattr__(self, name: str):
|
|
return getattr(self.supervisor.require_client(), name)
|