Add asynchronous machine over and out to shared MCP
This commit is contained in:
@@ -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())
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user