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
+11 -2
View File
@@ -21,7 +21,8 @@ async def probe(socket: str, source: str, foreign_id: int):
listed = await client.list_tools()
names = {tool.name for tool in listed.tools}
expected = {'session_status', 'where', 'read_registers', 'read_memory',
'recent_events', 'set_line_breakpoint', 'clear_breakpoint'}
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
'claim_control', 'release_control'}
if not expected <= names:
raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names))
@@ -39,6 +40,7 @@ async def probe(socket: str, source: str, foreign_id: int):
memory = await call('read_memory', {'address': hex(location['pc']), 'length': 4})
events = await call('recent_events', {'after': 0})
if not status['build_id'] or location['status'] != 'mapped' or \
not status['session_id'] or \
registers['registers']['PC'] != location['pc'] or \
len(memory['hex']) != 8 or not events['events']:
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
@@ -47,10 +49,17 @@ async def probe(socket: str, source: str, foreign_id: int):
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
if not foreign.is_error or 'не принадлежит' not in str(foreign.content):
raise RuntimeError('MCP не подтвердил защиту чужой точки VS Code')
lease = await call('claim_control')
if not lease['owner'].startswith('mcp:'):
raise RuntimeError('MCP не захватил управление CPU: ' + repr(lease))
released = await call('release_control')
if released['released'] != lease['owner']:
raise RuntimeError('MCP не освободил управление CPU: ' + repr(released))
print(json.dumps({'event': 'mcp_shared_session_verified',
'build_id': status['build_id'], 'pc': location['pc'],
'tools': len(names), 'foreign_point_rejected': True,
'owned_point_for_cleanup': own['id']},
'owned_point_for_cleanup': own['id'],
'control_claim_release': True},
ensure_ascii=False), flush=True)
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Живой raw MCP-пробник на C-приложении hello и подготовленной DSS-среде."""
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
ROOT = Path(__file__).resolve().parents[2]
MAME_REPO = ROOT.parent / "MAME.HT"
sys.path.insert(0, str(ROOT / "toolchain"))
from make_disk import create_floppy_image # noqa: E402
from mame_interactive import basename_83, build_events # noqa: E402
from mame_profile import write_keyboard_config # noqa: E402
from sdbg.image import read_ihx # noqa: E402
from sdbg.model import DebugMap # noqa: E402
from sdbg_launcher import lua_script # noqa: E402
def answer_text(result) -> str:
return "".join(item.text for item in result.content if item.type == "text")
async def call(session: ClientSession, name: str, arguments=None) -> str:
result = await session.call_tool(name, arguments or {})
value = answer_text(result)
if result.isError or value.startswith("ERROR:"):
raise AssertionError(f"{name}: {value}")
return value
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--show-seconds", type=float, default=0,
help="Сколько секунд оставить видимым экран hello перед вводом x")
parser.add_argument("--proof", type=Path,
help="Сохранить снимок работающего hello вне временной сессии")
args = parser.parse_args()
home = Path(os.environ["MAME_HOME"]).resolve()
binary = Path(os.environ.get("MAME_BIN", MAME_REPO / "sprinter")).resolve()
rompath = Path(os.environ.get("MAME_ROMPATH", home / "roms")).resolve()
package = ROOT / "tests/hello/.sprinter-cc-hello"
model = DebugMap(package)
model.verify_executable()
executable = Path(model.manifest["executable_path"])
function = next(item for item in model.functions if item["name"] == "main")
main_address = function["start"]
image = read_ihx(package / (executable.stem + ".ihx"))
signature = bytes(image[addr] for addr in range(
function["start"], min(function["end"], function["start"] + 24)))
variable = next(item for item in model.variables if item["name"] == "errno")
raw_address = 0x10000 + variable["logical_address"]
with tempfile.TemporaryDirectory(prefix="sprinter-raw-hello-") as directory:
state = Path(directory)
for name in ("ipc", "cfg", "nvram", "diff", "snapshot"):
(state / name).mkdir()
write_keyboard_config(state / "cfg")
shutil.copyfile(home / "IMG/sp_hdd_sys.chd", state / "system.chd")
disk = state / "debug.img"
if not create_floppy_image(str(disk),
[(basename_83(executable.name), str(executable))]):
raise RuntimeError("Не удалось создать тестовую дискету")
ready = state / "main.json"
lua = state / "launch.lua"
events = build_events([(0, "a:\\" + basename_83(executable.name).replace(" ", "") + "\n")])
lua_script(lua, ready, main_address, signature, events, 0, 30)
env = dict(os.environ, MAME_MCP_DIR=str(state / "ipc"),
MAME_MCP_SNAP_DIR=str(state / "snapshot"),
SDL_NO_SIGNAL_HANDLERS="1")
command = [str(binary), "sprinter", "-noreadconfig", "-rompath", str(rompath),
"-bios", "v3.06", "-kbd", "ms_naturl,bios=sp2k",
"-video", "soft", "-window", "-sound", "none", "-skip_gameinfo",
"-beta:wd179x:0", "35hd", "-beta:wd179x:1", "35hd",
"-flop1", str(disk), "-flop2", str(home / "IMG/dss171u.img"),
"-hard1", str(state / "system.chd"),
"-debug", "-debugger", "sdbg", "-plugin", "mamebridge",
"-pluginspath", str(MAME_REPO / "plugins"),
"-autoboot_delay", "0", "-autoboot_script", str(lua)]
for name in ("cfg", "nvram", "diff", "snapshot"):
command.extend(["-" + name + "_directory", str(state / name)])
log_path = state / "mame.log"
with log_path.open("w") as log:
machine = subprocess.Popen(command, cwd=state, env=env,
stdout=log, stderr=subprocess.STDOUT)
try:
params = StdioServerParameters(
command=sys.executable, args=[str(MAME_REPO / "src/mame_mcp.py")],
env=env)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
deadline = time.monotonic() + 75
await session.initialize()
while not ready.exists():
if machine.poll() is not None:
raise RuntimeError("MAME завершился до main")
if (state / "main.json.error").exists():
raise RuntimeError((state / "main.json.error").read_text())
if time.monotonic() > deadline:
raise TimeoutError("MAME не дошёл до main")
await asyncio.sleep(0.1)
status = await call(session, "status")
if not status.startswith("state=stop"):
raise AssertionError(f"Ожидалась остановка в main: {status}")
logical = await call(session, "read_logical_memory",
{"address": hex(variable["logical_address"]),
"length": 1})
original = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if logical.strip() != original.strip():
raise AssertionError(f"logical/raw memory diverged: {logical}/{original}")
changed = int(original, 16) ^ 1
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": f"{changed:02X}"})
actual = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if actual.strip() != f"{changed:02X}":
raise AssertionError(f"write_memory failed: {actual}")
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": original.strip()})
restored = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if restored.strip() != original.strip():
raise AssertionError("scratch byte not restored")
shares = await call(session, "list_shares")
if "vram" not in shares:
raise AssertionError(f"VRAM share missing: {shares}")
await call(session, "read_vram", {"address": "0", "length": 4})
await call(session, "read_share",
{"name": "vram", "address": "0", "length": 4})
pixels = await call(session, "read_screen_pixels",
{"x": 0, "y": 0, "width": 2, "height": 2})
if not pixels.strip():
raise AssertionError("screen pixels are empty")
await call(session, "list_ports")
await call(session, "disassemble",
{"address": hex(0x10000 + main_address), "num_bytes": 16})
shot = await call(session, "screenshot", {"name": "raw-hello.png"})
if "raw-hello.png" not in shot or not (state / "snapshot/raw-hello.png").is_file():
raise AssertionError(f"screenshot path missing: {shot}")
point = await call(session, "set_watchpoint",
{"address": hex(raw_address), "length": 2,
"access": "w", "space": "program"})
match = re.search(r"Watchpoint (\d+) set", point)
if not match:
raise AssertionError(f"watchpoint ID missing: {point}")
wp_id = int(match.group(1))
await call(session, "resume")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("errno watchpoint did not stop CPU")
await call(session, "clear_watchpoint", {"index": wp_id})
after_getchar = model.line_locations("hello.c", 63)["locations"][0]["logical_address"]
point = await call(session, "set_breakpoint",
{"address": hex(after_getchar)})
match = re.search(r"Breakpoint (\d+) set", point)
if not match:
raise AssertionError(f"breakpoint ID missing: {point}")
bp_id = int(match.group(1))
await call(session, "resume")
await asyncio.sleep(0.5)
running_shot = await call(session, "screenshot",
{"name": "hello-running.png"})
running_image = state / "snapshot/hello-running.png"
if "hello-running.png" not in running_shot or not running_image.is_file():
raise AssertionError(f"working hello screenshot missing: {running_shot}")
if args.proof:
args.proof.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(running_image, args.proof)
print(f"Снимок работающего hello: {args.proof}", flush=True)
if args.show_seconds:
await asyncio.sleep(args.show_seconds)
await call(session, "move_mouse", {"dx": 1, "dy": 0, "frames": 2})
await call(session, "click_mouse", {"button": "left", "frames": 2})
await call(session, "press_input",
{"port": ":JOY1", "mask": "0x400", "frames": 2})
await call(session, "set_input",
{"port": ":JOY1", "mask": "0x400", "value": 1,
"frames": 2})
await call(session, "press_key", {"key": "x", "frames": 3})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("press_key did not leave getchar")
if f"PC=0x{after_getchar:X}" not in status:
raise AssertionError(f"stopped before line 63: {status}")
await call(session, "clear_breakpoint", {"index": bp_id})
await call(session, "step_over", {"count": 1})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_over did not stop")
await call(session, "step_out")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_out did not stop")
await call(session, "resume")
await call(session, "type_string", {"text": "a"})
await call(session, "type_text", {"text": "b"})
print(f"main=0x{main_address:04X}; errno=0x{raw_address:X}; "
f"write/read-back OK; watchpoint={wp_id}; "
f"keyboard→line63=0x{after_getchar:04X}; "
f"pixels={pixels.strip()[:50]}; screenshot={running_shot.strip()}")
except BaseException:
print(log_path.read_text(errors="replace")[-4000:], file=sys.stderr)
raise
finally:
machine.terminate()
machine.wait(timeout=8)
if __name__ == "__main__":
asyncio.run(main())
+28
View File
@@ -96,6 +96,8 @@ def main():
parser.add_argument('--launch-path', default=None)
parser.add_argument('--mcp-python', default=None,
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
parser.add_argument('--orphan-expiry', action='store_true',
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
parser.add_argument('--exit-while-stopped', action='store_true',
@@ -152,6 +154,29 @@ def main():
capture_output=True, text=True, timeout=45, check=False)
if probe.returncode:
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
if options.orphan_expiry:
owner = 'mcp:orphan-expiry-probe'
orphan = rpc_call(launched['body']['socket'], 'break_line', {
'file': source, 'line': 62, 'owner': owner})
rpc_call(launched['body']['socket'], 'claim_control', {'owner': owner})
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
deadline = time.monotonic()+36
while time.monotonic() < deadline:
events = rpc_call(launched['body']['socket'], 'events', {'after': 0})
if any(event['event'] == 'owner_expired' and
event['body']['owner'] == owner for event in events['events']):
break
time.sleep(.5)
else:
raise RuntimeError('MCP owner не истёк за 36 с')
try:
rpc_call(launched['body']['socket'], 'clear_breakpoint', {
'id': orphan['id'], 'owner': owner})
except SessionError as error:
if 'Неизвестная логическая точка' not in str(error):
raise
else:
raise RuntimeError('MCP orphan-точка не была очищена')
send(process, 6, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10)
stopped, buffer = wait_event(process, buffer, 'stopped', 20)
@@ -170,6 +195,9 @@ def main():
if stale:
raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента')
print(probe.stdout.strip(), flush=True)
if options.orphan_expiry:
print(json.dumps({'event': 'mcp_orphan_expired_and_cleared',
'id': orphan['id']}, ensure_ascii=False), flush=True)
send(process, 10, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
if options.socket:
+2 -1
View File
@@ -37,7 +37,8 @@ class McpAdapterTests(unittest.TestCase):
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
self.client.events(after=4, timeout=12)
self.assertEqual(self.calls[-1][1:],
('events', {'after': 4, 'timeout': 12}, 14))
('events', {'after': 4, 'timeout': 12,
'owner': self.client.owner}, 14))
before = len(self.calls)
with self.assertRaisesRegex(SessionError, 'Адрес памяти'):
self.client.read_memory('not-an-address')
+59
View File
@@ -28,6 +28,7 @@ class DummyBridge:
if command == 'snapshot': return {'state': self.state}
if command == 'console_print': return {'printed': True}
if command == 'memory': return {'hex': '00' * arguments['length']}
if command == 'key': return {'accepted': True}
raise AssertionError(command)
@@ -217,6 +218,64 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_control_lease_blocks_other_client_and_can_be_released_or_expire(self):
controller = SessionController(DummySession())
try:
first = controller.call('claim_control', {'owner': 'mcp:a'})
self.assertEqual(first['owner'], 'mcp:a')
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
with self.assertRaisesRegex(SessionError, 'mcp:a'):
controller.call('claim_control', {'owner': 'mcp:b'})
controller.call('renew_control', {'owner': 'mcp:a'})
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
controller.call('release_control', {'owner': 'mcp:b'})
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
controller.call('release_control', {'owner': 'mcp:a'})
self.assertEqual(controller.call('claim_control', {'owner': 'mcp:b'})['owner'], 'mcp:b')
controller.control_deadline = 0
self.assertEqual(controller.call('claim_control', {'owner': 'cli'})['owner'], 'cli')
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
finally:
controller.close()
def test_expired_mcp_owner_clears_points_without_touching_dap_points(self):
session = DummySession()
controller = SessionController(session)
try:
mine = controller.call('break_line', {
'file': '/src/main.c', 'line': 3, 'owner': 'mcp:lost'})
dap = controller.call('break_function', {'name': 'main', 'owner': 'cli'})
controller.call('claim_control', {'owner': 'mcp:lost'})
controller.owner_deadlines['mcp:lost'] = 0
with controller.lock:
controller._reap_owners()
self.assertIn(mine['id'], session.cleared)
self.assertNotIn(dap['id'], session.cleared)
self.assertIsNone(controller.call('status', {})['control_owner'])
self.assertEqual(controller.events[-1]['event'], 'owner_expired')
finally:
controller.close()
def test_expired_owner_releases_held_key(self):
session = DummySession()
controller = SessionController(session)
try:
with controller.lock:
controller.running = True
session.bridge.state = 'running'
controller.call('input_key', {
'owner': 'mcp:lost', 'tag': ':kbd:ms_naturl',
'mask': 0x400, 'down': True})
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': True}), session.bridge.calls)
controller.owner_deadlines['mcp:lost'] = 0
controller._reap_owners()
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': False}), session.bridge.calls)
self.assertFalse(controller.held_inputs)
finally:
controller.close()
def test_read_memory_is_bounded_and_generation_tied(self):
session = DummySession()
controller = SessionController(session)