Add asynchronous machine over and out to shared MCP

This commit is contained in:
Александр Петров
2026-09-17 23:32:46 +03:00
parent 259c782190
commit a8d0692eb2
15 changed files with 241 additions and 19 deletions
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Живой MCP: долгий машинный out из tail-jump можно прервать Pause."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
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-out-mcp-', dir='/tmp') as temp:
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
'--socket', str(Path(temp) / 'session.sock'),
'--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
await call('start_session')
try:
deadline = time.monotonic() + 90
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('hello не дошёл до main')
entered = await call('step_instruction', {'count': 1})
code = await call('disassemble_logical',
{'address': hex(entered['pc']), 'length': 12})
if 'jp (hl)' not in code['text']:
raise RuntimeError('Ожидался tail-jump helper: ' + code['text'])
started_at = time.monotonic()
accepted = await call('step_out_instruction')
response_ms = round((time.monotonic() - started_at) * 1000, 1)
if not accepted['accepted'] or response_ms > 3000:
raise RuntimeError('Машинный out не ответил быстро: ' + repr(accepted))
await asyncio.sleep(.3)
if not (await call('session_status'))['running']:
raise RuntimeError('Tail-jump out неожиданно завершился')
paused = await call('pause_execution')
if (await call('session_status'))['running']:
raise RuntimeError('Pause не остановил долгий out')
print(json.dumps({'event': 'machine_out_pause_verified',
'response_ms': response_ms,
'entered_pc': entered['pc'],
'paused_pc': paused['pc']}, ensure_ascii=False),
flush=True)
finally:
await call('stop_session')
if __name__ == '__main__':
asyncio.run(probe())
+36 -1
View File
@@ -109,8 +109,43 @@ async def probe() -> None:
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'] == location['pc'] or \
if stepped['pc'] == returned['pc'] or \
(await call('session_status'))['running']:
raise RuntimeError('Три машинных шага не остановились: ' + repr(stepped))
source = str(ROOT / 'tests/hello/hello.c')
+9
View File
@@ -64,6 +64,15 @@ class McpAdapterTests(unittest.TestCase):
self.client.step_instruction(3)
self.assertEqual(self.calls[-1][1], 'step')
self.assertEqual(self.calls[-1][2]['count'], 3)
self.client.step_over_instruction(2)
self.assertEqual(self.calls[-1][1], 'step_over_instruction')
self.assertEqual(self.calls[-1][2]['count'], 2)
self.client.step_out_instruction()
self.assertEqual(self.calls[-1][1], 'step_out_instruction')
before = len(self.calls)
with self.assertRaisesRegex(SessionError, '1..64'):
self.client.step_over_instruction(0)
self.assertEqual(len(self.calls), before)
def test_press_key_releases_shift_after_snapshot_error(self):
calls = []
+44 -3
View File
@@ -118,7 +118,7 @@ class StepBridge:
def request(self, command, **arguments):
self.calls.append(command)
if command in ('step', 'step_over', 'step_out'):
count = arguments.get('count', 1) if command == 'step' else 1
count = arguments.get('count', 1) if command in ('step', 'step_over') else 1
self.step_counts.append(count)
self.owner.index = min(self.owner.index + count,
len(self.owner.locations) - 1)
@@ -153,7 +153,7 @@ class StepSession:
class WaitingBridge:
"""Машинный over ожидает внешний ввод, но pause должен остаться доступен."""
"""Машинный over/out ожидает ввод, но Pause должен остаться доступен."""
def __init__(self):
self.paused = False
self.started = False
@@ -168,7 +168,7 @@ class WaitingBridge:
if command == 'pause':
self.paused = True
return {'accepted': True}
if command == 'step_over':
if command in ('step_over', 'step_out'):
self.started = True
return {'accepted': True}
raise AssertionError(command)
@@ -500,6 +500,30 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_machine_over_and_out_are_separate_from_source_steps(self):
session = StepSession()
controller = SessionController(session)
try:
for bad in (0, 65, 1.5):
with self.assertRaisesRegex(SessionError, '1..64'):
controller.call('step_over_instruction',
{'count': bad, 'owner': 'mcp:test'})
over = controller.call('step_over_instruction',
{'count': 2, 'owner': 'mcp:test'})
self.assertEqual(over['accepted'], True)
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 4)
self.assertEqual(session.bridge.step_counts, [2])
session.index = 0
out = controller.call('step_out_instruction', {'owner': 'mcp:test'})
self.assertEqual(out['accepted'], True)
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 3)
self.assertEqual([name for name in session.bridge.calls
if name != 'snapshot'], ['step_over', 'step_out'])
finally:
controller.close()
def test_source_step_preserves_user_breakpoint_on_same_line(self):
session = StepSession()
controller = SessionController(session)
@@ -531,6 +555,23 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_waiting_machine_over_out_keep_pause_available(self):
for method in ('step_over_instruction', 'step_out_instruction'):
with self.subTest(method=method):
session = StepSession()
session.bridge = WaitingBridge()
controller = SessionController(session)
try:
started = controller.call(method, {'owner': 'mcp:test'})
self.assertTrue(started['accepted'])
self.assertTrue(controller.running)
stopped = controller.call('pause', {'owner': 'mcp:test'})
self.assertEqual(stopped['sources'][0]['line'], 3)
self.assertFalse(controller.running)
self.assertIn('pause', session.bridge.calls)
finally:
controller.close()
if __name__ == '__main__':
unittest.main()