Verify visible hello MCP run and add shared control lease

This commit is contained in:
Александр Петров
2026-09-17 18:15:01 +03:00
parent 619c4c9af5
commit a43e7bda89
14 changed files with 575 additions and 44 deletions
+7 -1
View File
@@ -54,6 +54,11 @@ class DapEngine:
'description': self._description(location)}))
return {}, events
if command == 'disconnect':
if self.attached is not None:
try:
self.rpc('release_control')
except (SessionError, OSError):
pass
return {}, events
if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
@@ -118,7 +123,8 @@ class DapEngine:
def poll_events(self, timeout=1):
previous = self.event_sequence
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout,
'owner': 'cli'},
timeout=timeout + 2)
self.event_sequence = result['last']
translated = []
+15 -5
View File
@@ -42,7 +42,8 @@ class McpSession:
def events(self, after: int = 0, timeout: float = 0):
if after < 0 or timeout < 0 or timeout > 30:
raise SessionError('after должен быть неотрицательным, timeout — от 0 до 30 с')
return self.call('events', {'after': after, 'timeout': timeout},
return self.call('events', {'after': after, 'timeout': timeout,
'owner': self.owner},
timeout=max(10, timeout + 2))
def console_tail(self, count: int = 40):
@@ -63,15 +64,24 @@ class McpSession:
return self.call('clear_owned_breakpoints', {'owner': self.owner})
def continue_execution(self):
return self.call('continue')
return self.call('continue', {'owner': self.owner})
def pause_execution(self):
return self.call('pause')
return self.call('pause', {'owner': self.owner})
def step_instruction(self):
return self.call('step')
return self.call('step', {'owner': self.owner})
def step_source(self, kind: str = 'into'):
if kind not in ('into', 'over', 'out'):
raise SessionError('kind должен быть into, over или out')
return self.call('source_step', {'kind': kind})
return self.call('source_step', {'kind': kind, 'owner': self.owner})
def claim_control(self):
return self.call('claim_control', {'owner': self.owner})
def renew_control(self):
return self.call('renew_control', {'owner': self.owner})
def release_control(self):
return self.call('release_control', {'owner': self.owner})
+105 -5
View File
@@ -9,6 +9,7 @@ import socketserver
import string
import threading
import time
import uuid
from .session import DebugSession, SessionError
from .transport import BridgeError
@@ -16,6 +17,8 @@ from .macros import validate_log_message
class SessionController:
CONTROL_TTL = 30.0
def __init__(self, session: DebugSession):
self.session = session
self.lock = threading.RLock()
@@ -26,6 +29,11 @@ class SessionController:
self.running = False
self.source_step = None
self.closed = False
self.session_id = uuid.uuid4().hex
self.control_owner: str | None = None
self.control_deadline = 0.0
self.owner_deadlines: dict[str, float] = {}
self.held_inputs: dict[str, set[tuple[str, int]]] = {}
self.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {}
@@ -84,6 +92,7 @@ class SessionController:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
if self.running:
self._release_all_inputs()
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
@@ -104,6 +113,7 @@ class SessionController:
self._emit('invalidated', {'reason': 'reset_or_load'})
if not self.running:
next_idle_snapshot = time.monotonic() + .5
self._reap_owners()
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None
@@ -114,6 +124,11 @@ class SessionController:
def close(self) -> None:
self.closed = True
self.poller.join(timeout=1)
with self.lock:
try:
self._release_all_inputs()
except (BridgeError, SessionError, OSError):
pass
self.session.bridge.close()
@staticmethod
@@ -123,8 +138,59 @@ class SessionController:
raise SessionError('owner должен быть непустой строкой до 128 символов')
return owner
def _control(self, arguments: dict) -> dict:
"""Захватить или продлить управление; чужой живой lease не отбирать."""
owner = self._owner(arguments)
self._touch_owner(owner)
now = time.monotonic()
if self.control_owner is not None and self.control_deadline <= now:
self.control_owner = None
if self.control_owner not in (None, owner):
raise SessionError(f'CPU управляет {self.control_owner}; дождитесь release или истечения lease')
if self.control_owner != owner:
self.control_owner = owner
self._emit('control', {'owner': owner})
self.control_deadline = now + self.CONTROL_TTL
return {'owner': owner, 'expires_in': self.CONTROL_TTL}
def _touch_owner(self, owner: str) -> None:
if owner.startswith('mcp:'):
self.owner_deadlines[owner] = time.monotonic() + self.CONTROL_TTL
def _release_inputs(self, owner: str) -> None:
for tag, mask in self.held_inputs.pop(owner, set()):
self.session.bridge.request('key', tag=tag, mask=mask, down=False)
def _release_all_inputs(self) -> None:
for owner in list(self.held_inputs):
self._release_inputs(owner)
def _reap_owners(self) -> None:
now = time.monotonic()
for owner, deadline in list(self.owner_deadlines.items()):
if deadline > now:
continue
self._release_inputs(owner)
identifiers = [identifier for identifier, info in self.breakpoint_info.items()
if info.get('owner') == owner]
for identifier in identifiers:
self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
self.owner_deadlines.pop(owner, None)
self._emit('owner_expired', {'owner': owner, 'cleared_breakpoints': identifiers})
def call(self, method: str, arguments: dict) -> object:
if method == 'events':
owner = arguments.get('owner')
if owner is not None:
with self.lock:
if self.control_owner == self._owner(arguments) and \
self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout
@@ -138,12 +204,32 @@ class SessionController:
with self.lock:
if self.closed:
raise SessionError('Сессия закрыта или инвалидирована')
if method == 'claim_control':
return self._control(arguments)
if method == 'renew_control':
owner = self._owner(arguments)
self._touch_owner(owner)
if self.control_owner == owner and self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
return {'owner': owner, 'has_control': True,
'expires_in': self.CONTROL_TTL}
return {'owner': owner, 'has_control': False}
if method == 'release_control':
owner = self._owner(arguments)
self._release_inputs(owner)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
return {'released': owner}
if self.running and method in ('where', 'registers', 'read_memory',
'read_variable', 'break_line', 'break_function'):
raise SessionError('Операция требует остановленного CPU; сначала Pause')
if method == 'status':
return {**self.attached, 'running': self.running,
'event_sequence': self.sequence}
'event_sequence': self.sequence, 'session_id': self.session_id,
'control_owner': self.control_owner if
self.control_deadline > time.monotonic() else None}
if method == 'snapshot':
return self.session.bridge.request('snapshot')
if method == 'mame_console_tail':
@@ -152,9 +238,16 @@ class SessionController:
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request(
'key', tag=arguments['tag'], mask=int(arguments['mask']),
down=arguments['down'])
self._control(arguments)
tag, mask = arguments['tag'], int(arguments['mask'])
result = self.session.bridge.request('key', tag=tag, mask=mask,
down=arguments['down'])
held = self.held_inputs.setdefault(self._owner(arguments), set())
if arguments['down']:
held.add((tag, mask))
else:
held.discard((tag, mask))
return result
if method == 'where':
return self.session.where()
if method == 'registers':
@@ -177,6 +270,7 @@ class SessionController:
return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line':
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_line(arguments['file'], int(arguments['line']))
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -184,6 +278,7 @@ class SessionController:
return item
if method == 'break_function':
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_function(arguments['name'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -192,7 +287,8 @@ class SessionController:
if method == 'clear_breakpoint':
identifier = int(arguments['id'])
owner = arguments.get('owner')
if owner is not None and self.breakpoint_info.get(identifier, {}).get('owner') != owner:
if identifier in self.breakpoint_info and owner is not None and \
self.breakpoint_info[identifier].get('owner') != owner:
raise SessionError('Точка не принадлежит этому клиенту')
result = self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
@@ -217,11 +313,13 @@ class SessionController:
if method == 'continue':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
result = self.session.bridge.request('continue')
self.running = True
self._emit('continued', {})
return result
if method == 'pause':
self._control(arguments)
self.source_step = None
self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped())
@@ -231,6 +329,7 @@ class SessionController:
if method == 'step':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
self.session.bridge.request('step')
self.running = True
self._emit('continued', {'reason': 'step'})
@@ -239,6 +338,7 @@ class SessionController:
self._emit('stopped', {'reason': 'step', 'location': location})
return location
if method == 'source_step':
self._control(arguments)
return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method)
+30
View File
@@ -6,6 +6,7 @@ import argparse
from functools import wraps
import os
import sys
import threading
from typing import Any
from sdbg.mcp_adapter import McpSession
@@ -102,6 +103,16 @@ def make_server(client: McpSession):
"""Продолжить CPU; событие остановки читать через recent_events."""
return client.continue_execution()
@tool()
def claim_control() -> dict[str, Any]:
"""Захватить управление CPU, если оно свободно; конфликт возвращает текущего владельца."""
return client.claim_control()
@tool()
def release_control() -> dict[str, Any]:
"""Освободить своё управление CPU для VS Code или другого MCP-клиента."""
return client.release_control()
@tool()
def pause_execution() -> dict[str, Any]:
"""Остановить CPU и вернуть текущую C-позицию."""
@@ -128,8 +139,20 @@ def main() -> int:
if not args.socket:
parser.error('укажите --socket или SDBG_SOCKET')
client = McpSession(args.socket)
heartbeat_stop = threading.Event()
heartbeat = None
try:
client.status() # Не публикуем инструменты для отсутствующей сессии.
def renew_control():
while not heartbeat_stop.wait(10):
try:
client.renew_control()
except (SessionError, OSError):
# Без lease обновлять нечего; следующая мутация захватит его.
pass
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
daemon=True)
heartbeat.start()
server = make_server(client)
server.run(transport='stdio')
return 0
@@ -137,6 +160,13 @@ def main() -> int:
print('sdbg-mcp: ' + str(error), file=sys.stderr)
return 1
finally:
heartbeat_stop.set()
if heartbeat is not None:
heartbeat.join(timeout=1)
try:
client.release_control()
except (SessionError, OSError):
pass
try:
client.clear_owned_breakpoints()
except (SessionError, OSError):