Files
Sprinter-SDCC/tests/sdbg/run_managed_mcp_probe.py
T
2026-09-17 23:32:46 +03:00

228 lines
13 KiB
Python

#!/usr/bin/env python3
"""MCP сам запускает hello, затем DAP подключается к тому же MAME."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_vscode_dap_probe import send, wait_response # noqa: E402
async def probe() -> None:
home = Path(os.environ['MAME_HOME']).resolve()
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
package = ROOT / 'tests/hello/.sprinter-cc-hello'
with tempfile.TemporaryDirectory(prefix='sprinter-managed-mcp-', dir='/tmp') as temp:
socket = str(Path(temp) / 'session.sock')
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
'--socket', socket, '--mame-home', str(home),
'--mame-bin', str(binary)])
async with Client(parameters) as client:
async def call(name: str, arguments: dict | None = None) -> dict:
result = await client.call_tool(name, arguments or {})
if result.is_error or result.structured_content is None:
raise RuntimeError(name + ': ' + repr(result.content))
return result.structured_content
names = {tool.name for tool in (await client.list_tools()).tools}
if not {'start_session', 'stop_session', 'where', 'read_variable',
'press_key', 'list_breakpoints', 'disassemble_logical'} <= names:
raise RuntimeError('Нет инструментов автономного запуска')
initial = await call('session_status')
if initial['phase'] != 'idle':
raise RuntimeError('Ожидался idle: ' + repr(initial))
started_at = time.monotonic()
accepted = await call('start_session')
start_ms = round((time.monotonic() - started_at) * 1000, 1)
if not accepted['accepted'] or start_ms > 3000:
raise RuntimeError('MCP start_session не ответил быстро: ' + repr(accepted))
duplicate = await client.call_tool('start_session', {})
if not duplicate.is_error or 'уже запущена' not in str(duplicate.content):
raise RuntimeError('Повторный start_session не был отклонён')
mame_pid = None
try:
deadline = time.monotonic() + 85
while time.monotonic() < deadline:
status = await call('session_status')
if status['phase'] == 'ready':
break
if status['phase'] in ('failed', 'stopped'):
raise RuntimeError('Launcher: ' + repr(status))
await asyncio.sleep(.2)
else:
raise TimeoutError('MCP session не дошла до main')
mame_pid = status['mame_pid']
location = await call('where')
if location.get('function', {}).get('name') != 'main':
raise RuntimeError('MCP не остановился в main: ' + repr(location))
bytes_before = await call('read_memory',
{'address': hex(location['pc']), 'length': 32})
disassembly = await call('disassemble_logical',
{'address': hex(location['pc']), 'length': 32})
bytes_after = await call('read_memory',
{'address': hex(location['pc']), 'length': 32})
if disassembly['space'] != 'logical_z80' or \
disassembly['address'] != location['pc'] or \
not disassembly['text'].upper().startswith(
f"{location['pc']:04X}:") or \
bytes_before['hex'] != bytes_after['hex']:
raise RuntimeError('Нет дизассемблирования main: ' + repr(disassembly))
await call('read_variable', {'name': 'errno'})
shot = await call('screenshot')
if Path(shot['path']).read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
raise RuntimeError('MCP не создал PNG')
dap = subprocess.Popen([sys.executable, str(ROOT / 'toolchain/sdbg_dap.py')],
cwd=ROOT, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
buffer = b''
try:
send(dap, 1, 'initialize')
_, buffer, _ = wait_response(dap, buffer, 'initialize', 10)
send(dap, 2, 'attach', {'socket': socket})
_, buffer, _ = wait_response(dap, buffer, 'attach', 10)
send(dap, 3, 'configurationDone')
_, buffer, _ = wait_response(dap, buffer, 'configurationDone', 10)
send(dap, 4, 'stackTrace')
frame, buffer, _ = wait_response(dap, buffer, 'stackTrace', 10)
if frame['body']['stackFrames'][0]['name'] != 'main':
raise RuntimeError('DAP attach не увидел main')
send(dap, 5, 'disconnect')
wait_response(dap, buffer, 'disconnect', 10)
finally:
if dap.poll() is None:
dap.terminate()
dap.communicate(timeout=8)
after_dap = await call('session_status')
if after_dap['phase'] != 'ready' or after_dap['session_id'] != status['session_id']:
raise RuntimeError('DAP disconnect завершил MCP-owned MAME')
lines = disassembly['text'].splitlines()
addresses = [int(line.split(':', 1)[0], 16) for line in lines]
calls = [index for index, line in enumerate(lines)
if ' call ' in ' ' + line.lower() + ' ']
if len(calls) < 2 or calls[0] != 0 or calls[1] <= 1:
raise RuntimeError('Нет двух call в main: ' + disassembly['text'])
async def wait_machine_stop() -> dict:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if not (await call('session_status'))['running']:
return await call('where')
await asyncio.sleep(.05)
paused = await call('pause_execution')
raise TimeoutError('Машинный шаг не завершился; Pause PC=' +
hex(paused['pc']))
await call('step_over_instruction', {'count': 1})
after_over = await wait_machine_stop()
if after_over['pc'] != addresses[1]:
raise RuntimeError('over call не остановился после вызова: ' +
repr(after_over))
await call('step_over_instruction', {'count': calls[1] - 1})
at_call = await wait_machine_stop()
if at_call['pc'] != addresses[calls[1]]:
raise RuntimeError('over count не дошёл до второго call: ' +
repr(at_call))
entered = await call('step_instruction', {'count': 1})
if entered['pc'] == at_call['pc']:
raise RuntimeError('Машинный step не вошёл в возвращаемый вызов')
await call('step_out_instruction')
returned = await wait_machine_stop()
if returned['pc'] != addresses[calls[1] + 1]:
raise RuntimeError('Машинный out не вернулся после call: ' +
repr(returned))
stepped = await call('step_instruction', {'count': 3})
if stepped['pc'] == returned['pc'] or \
(await call('session_status'))['running']:
raise RuntimeError('Три машинных шага не остановились: ' + repr(stepped))
source = str(ROOT / 'tests/hello/hello.c')
before = await call('set_line_breakpoint', {'file': source, 'line': 62})
points = await call('list_breakpoints')
active = [item for item in points['breakpoints']
if item['id'] == before['id']]
if len(active) != 1 or not active[0]['owner'].startswith('mcp:') or \
not any(loc.get('line') == 62 for loc in active[0]['locations']):
raise RuntimeError('Личная точка не отражена в списке: ' + repr(points))
await call('continue_execution')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
at_getchar = await call('session_status')
if not at_getchar['running']:
break
await asyncio.sleep(.1)
else:
raise TimeoutError('MCP не остановился перед getchar')
location = await call('where')
if not any(item['line'] == 62 for item in location['sources']):
raise RuntimeError('MCP остановился не перед getchar: ' + repr(location))
await call('clear_breakpoint', {'identifier': before['id']})
after = await call('set_line_breakpoint', {'file': source, 'line': 63})
await call('continue_execution')
await asyncio.sleep(.4)
running = await call('session_status')
if not running['running']:
raise RuntimeError('CPU не ожидает getchar перед вводом')
while_running = await call('list_breakpoints')
if not any(item['id'] == after['id']
for item in while_running['breakpoints']):
raise RuntimeError('Не прочитана точка при running CPU')
key = await call('press_key', {'key': 'x', 'frames': 3})
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
after_key = await call('session_status')
if not after_key['running']:
break
await asyncio.sleep(.1)
else:
raise TimeoutError('MCP press_key не завершил getchar')
location = await call('where')
if not any(item['line'] == 63 for item in location['sources']):
raise RuntimeError('После клавиши ожидалась строка 63: ' + repr(location))
await call('clear_breakpoint', {'identifier': after['id']})
print(json.dumps({'event': 'mcp_launch_dap_attach_verified',
'tools': len(names), 'start_ms': start_ms,
'session_id': status['session_id'],
'socket': socket, 'pc': location['pc'],
'disassembly_head': disassembly['text'][:90],
'key': key['key'], 'after_getchar_line': 63},
ensure_ascii=False), flush=True)
finally:
await call('stop_session')
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
ended = await call('session_status')
if ended['phase'] == 'stopped':
break
await asyncio.sleep(.1)
else:
raise RuntimeError('stop_session не завершил launcher')
if Path(socket).exists():
raise RuntimeError('После stop_session остался Unix socket')
if mame_pid is not None:
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
try:
os.kill(mame_pid, 0)
except ProcessLookupError:
break
await asyncio.sleep(.1)
else:
raise RuntimeError('После stop_session остался процесс MAME')
if __name__ == '__main__':
asyncio.run(probe())