diff --git a/docs/TODO.md b/docs/TODO.md index 6c68ebd..86d6114 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -280,16 +280,18 @@ Quick wins: ## Прочий backlog - [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый - stdio-адаптер с 16 инструментами использует общую sdbg-сессию и + stdio-адаптер с 18 инструментами использует общую sdbg-сессию и owner ID личных точек. Полный план и матрица всех 30 raw-возможностей — [mcp-convergence-plan.md](mcp-convergence-plan.md). Следующее: - эксклюзивный control lease для - DAP/MCP, heartbeat/очистка точек при аварийном выходе MCP, безопасные + control lease для DAP/MCP и cleanup точек по истечении heartbeat + проверены живым прогоном. Далее проверка + session ID изменяющих команд, безопасные input/screenshot/console API через session server, затем доказанные watchpoints, запись typed values, disassemble_src и batch-загрузка точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же - MAME в обход общей сессии; старый `mame_bridge.lua` удалить лишь после - проверки 16 общих команд и обновления ссылок. + MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT + после сверки 16 общих команд и живых MCP-прогонов; доказательства — в + [mcp-convergence-plan.md](mcp-convergence-plan.md). Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения raw и C-адаптера разделены. diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index 728f726..7dbabf5 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени проверяет сигнатуру entry и запускает session server. Закрытие DAP завершает только созданные им server/MAME, удаляет временный каталог и свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс). -- `sdbg_mcp.py` предоставляет 16 инструментов через официальный MCP SDK +- `sdbg_mcp.py` предоставляет 18 инструментов через официальный MCP SDK 2.x поверх того же session server: статус, C-позиция, регистры, logical memory, переменные, события/логи, личные точки и команды исполнения. MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном @@ -284,6 +284,9 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `0x42c9` | | VS Code stdio DAP launch, 2026-09-15 | Local pyenv shim → DAP → изолированный MAME/DSS → `hello` → `main:17`, PC=`0x8224`; `false_hits=0`, штатный disconnect | | MAME.HT 0.289, 2026-09-17 | Чистая сборка `make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp` и отдельные рецепты `stock`/`sdbg` создали `sprinter`; DAP в `sdbg` и `osx` на patched, а также `osx` на stock дождался DSS и остановился в `hello.c:17` без ложных точек. `getchar()` получил `x`, общий DAP/MCP сохранил ownership точек. Raw `mamebridge` через официальный MCP stdio-клиент перечислил 30 инструментов и ответил на `status`/`read_registers` при остановленном CPU; устаревший `resp_1.txt` не подменил ответ после перезапуска frontend | +| Raw MCP plugin/legacy, 2026-09-17 | Все 16 команд удалённого `mame_bridge.lua` найдены в plugin dispatch; MCP stdio-прогоны проверили общий running-набор. Plugin на `hello` прошёл DSS→`main`, запись/восстановление `errno`, реальное срабатывание watchpoint, ввод `x` до строки 63 после `getchar()`, машинные over/out, снимок и пиксели. После удаления legacy-файла оба plugin-пробника повторно прошли | +| Видимый raw MCP `hello`, 2026-09-17 | `tests/sdbg/run_raw_mcp_hello_probe.py` запустил MAME.HT с `-video soft -window`; кадр работающего `hello` показал весь текст и `Press any key to exit...`, затем ввод `x` довёл PC до `hello.c:63` | +| Control lease DAP/MCP, 2026-09-17 | 18 C-инструментов, `claim_control`/`release_control`, 30-секундный lease с heartbeat; живой DAP+MCP-прогон подтвердил передачу управления обратно DAP и попадание в его точку. Второй видимый прогон дождался `owner_expired`, проверил удаление orphan-точки MCP и отсутствие ложной остановки; отпускание клавиш проверено unit-тестом | | F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg | | VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` | | Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | @@ -377,10 +380,10 @@ Crash reports в 19:22/19:23 относились к неподдержанно строит проверенное условие через I/O port. После штатных прогонов в 20:43 и позже новых `mame.arm-*.ips` не появилось. -Далее по плану: арбитраж управления CPU и lease/cleanup для MCP-точек при +Далее по плану: завершить арбитраж управления CPU и cleanup для MCP-точек при аварийном выходе клиента, затем безопасный restart и расширенные выражения. -Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с новым -`-plugin mamebridge` либо старым `mame_bridge.lua`) нельзя просто перенести: они должны +Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с +`-plugin mamebridge`) нельзя просто перенести: они должны проходить через общий session server и получить проверенный контракт для ввода, скриншотов, записи/watchpoints. VSIX уже упакован; для IDE ещё нужны Run-команда, выбор профиля diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index b4fde7a..031d98c 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -536,12 +536,14 @@ console_log и загрузка набора точек. Возвращать bu в арбитраж, а не обходят его. Первый stdio MCP-адаптер уже работает через общий session server и отдаёт -16 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой +18 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, -точки и последующее срабатывание DAP-точки. Эксклюзивный control lease, -cleanup при аварийном выходе MCP и остальные интерфейсы этого раздела ещё -не реализованы. Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md); -проверка legacy Lua и перенос всех raw MCP-возможностей в общую сессию — +точки и последующее срабатывание DAP-точки. Добавлен control lease для +команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat +проверены живым прогоном; +остальные интерфейсы этого раздела ещё не реализованы. +Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md); +результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию — [mcp-convergence-plan.md](mcp-convergence-plan.md). ### 8.2 DAP MVP и развитие diff --git a/docs/mcp-convergence-plan.md b/docs/mcp-convergence-plan.md index 607b342..c2b630a 100644 --- a/docs/mcp-convergence-plan.md +++ b/docs/mcp-convergence-plan.md @@ -8,10 +8,10 @@ проверку C-карты, банки, события и владение точками. Один процесс MAME не загружает одновременно `mamebridge` и `sdbgbridge`. -Сейчас `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30 -raw-инструментов. Старый `src/mame_bridge.lua` использует тот же Python -frontend, но обрабатывает только 16 команд и не отвечает при hard-stop. -`toolchain/sdbg_mcp.py` публикует 16 C-инструментов через общую с DAP сессию. +`MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30 +raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот +же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop. +`toolchain/sdbg_mcp.py` публикует 18 C-инструментов через общую с DAP сессию. **Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не механически скопировать имена и небезопасную семантику.** Полный raw MCP остаётся отдельным режимом для задач без C-пакета и других машин MAME. @@ -60,6 +60,20 @@ C-MCP, ограничения совместной работы с DAP и жив проверяемых ссылок на удалённый script в текущем способе запуска нет. До этого шага считать legacy устаревшим, но не удалённым. +**Статус А, 2026-09-17 — выполнено.** Сравнение Lua-dispatch показало 16/16 +общих команд. Последовательные MCP stdio-прогоны на MAME.HT проверили +регистры, память, дизассемблирование, консоль, точки/watchpoints и +resume/pause у legacy при running CPU и plugin при stop. На реальном `hello` +plugin прошёл DSS→`main`, запись и восстановление `errno`, попадание в +watchpoint, ввод `x` через `getchar()` и машинные шаги over/out. Старый +frame notifier не смог обслужить запрос после service-stop в `main`, что +подтвердило известное ограничение. Файл удалён из активного дерева MAME.HT; +после удаления оба plugin-пробника повторно прошли. Повторный прогон `hello` +с `-video soft -window` показал вывод программы и `Press any key to exit...` +на снимке работающего экрана; затем `x` довёл PC до строки после `getchar()`. +Пробник `tests/sdbg/run_raw_mcp_hello_probe.py` теперь по умолчанию использует +видимое окно, а `--proof` сохраняет снимок вне временного каталога сессии. + ## Б. Подготовить общую C-сессию к расширению 1. Ввести control lease между DAP и MCP для `continue`, `pause`, шагов, @@ -82,6 +96,21 @@ C-MCP, ограничения совместной работы с DAP и жив **Выход Б:** два MCP-клиента и DAP читают одну сессию; конкурентные мутации сериализованы, а потеря клиента не оставляет его точек или нажатых клавиш. +**Промежуточный статус Б, 2026-09-17.** В session server добавлен 30-секундный +control lease: команды CPU и ввод принимаются от текущего владельца, чужие +получают явный отказ. MCP предоставляет `claim_control`/`release_control` и +продлевает lease фоновым heartbeat; DAP продлевает его при опросе событий и +освобождает при disconnect. Каждый процесс session server выдаёт новый +`session_id`. Unit-тест проверил конфликт, освобождение и истечение lease; +живой DAP+MCP-прогон проверил передачу управления обратно VS Code и попадание +в его точку. Сервер теперь также удаляет личные MCP-точки и отпускает +удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем +без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие +ложной остановки в `hello.c:62` после возобновления DAP. +**Этап Б не закрыт:** ещё нужны защита +всех мутаций, проверка session ID в каждом изменяющем запросе и безопасная +invalidation при reset/load. + ## В. Перенести 30 возможностей raw MCP по группам | Raw-инструменты | Реализация в общей сессии и проверка | diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md index d7e3234..a6efaf2 100644 --- a/docs/sdbg-mcp.md +++ b/docs/sdbg-mcp.md @@ -2,11 +2,10 @@ `toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же -сессию, карту сборки и журнал, что VS Code/DAP. В репозитории MAME есть две -Lua-реализации **другого**, raw MCP-моста: `src/mame_bridge.lua` -(`-autoboot_script`) и более новый `-plugin mamebridge`. Оба обслуживают -один `src/mame_mcp.py` и одинаковый файловый транспорт, но legacy Lua -обрабатывает только 16 из 30 публикуемых Python-команд. Plugin использует +сессию, карту сборки и журнал, что VS Code/DAP. В MAME.HT отдельно работает +raw MCP-мост `-plugin mamebridge` с `src/mame_mcp.py`. Старый +`src/mame_bridge.lua` удалён после проверки: он обрабатывал лишь 16 из 30 +публикуемых Python-команд и не отвечал при остановленном CPU. Plugin использует `register_periodic` и отвечает даже при остановленном CPU. Ни один из этих вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии. `src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x; @@ -48,7 +47,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А Доступные инструменты: `session_status`, `where`, `read_registers`, `read_memory`, `list_variables`, `read_variable`, `recent_events`, `mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`, -`clear_breakpoint`, `clear_my_breakpoints`, `continue_execution`, +`clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`, +`continue_execution`, `pause_execution`, `step_instruction`, `step_source`. Ответы SDK 2.x содержат `structuredContent`. `step_source` принимает `into`, `over` или `out` и возвращает принятие команды; итоговую остановку получите через @@ -71,8 +71,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить `mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять CPU и точки без общего owner ID и журнала. -Поэтапные проверки перед удалением legacy Lua и перенос всех 30 возможностей -в общую C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md). +Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую +C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md). Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт logical Z80 memory, только при остановленном CPU, без side effects. Ответ содержит @@ -86,10 +86,16 @@ generation и страницы банков; диапазон не может п удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP. Удаление личной точки при running CPU отдельно проверено на ожидании `getchar()`; оно не ставит CPU на паузу и не блокирует ввод. -Общий session server сериализует команды, но эксклюзивного владельца -управления CPU пока нет: не посылайте `continue`/`step` одновременно из MCP -и VS Code. При аварийном завершении MCP его точки могут остаться до конца -сессии MAME; нужна отдельная lease/cleanup-механика. Несколько одновременно +Общий session server выдаёт 30-секундный control lease первому владельцу +команды CPU; MCP может захватить/освободить его явно. Фоновый heartbeat MCP +и опрос событий DAP продлевают активный lease. Конкурентная команда получает +отказ с именем владельца, после штатного закрытия lease освобождается. +При аварийном завершении MCP lease истекает за 30 секунд; сервер удаляет +личные точки и отпускает удерживаемые клавиши. Живой прогон с владельцем без +heartbeat подтвердил удаление его точки и отсутствие ложной остановки после +возобновления DAP; освобождение удерживаемой клавиши пока проверено только +unit-тестом. +Несколько одновременно запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест. Проверка совместной работы с DAP и реальным MAME: diff --git a/tests/sdbg/run_mcp_sdk_probe.py b/tests/sdbg/run_mcp_sdk_probe.py index 0636c97..2de5c1b 100644 --- a/tests/sdbg/run_mcp_sdk_probe.py +++ b/tests/sdbg/run_mcp_sdk_probe.py @@ -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) diff --git a/tests/sdbg/run_raw_mcp_hello_probe.py b/tests/sdbg/run_raw_mcp_hello_probe.py new file mode 100644 index 0000000..ed8922c --- /dev/null +++ b/tests/sdbg/run_raw_mcp_hello_probe.py @@ -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()) diff --git a/tests/sdbg/run_vscode_dap_probe.py b/tests/sdbg/run_vscode_dap_probe.py index ada9720..13234a0 100644 --- a/tests/sdbg/run_vscode_dap_probe.py +++ b/tests/sdbg/run_vscode_dap_probe.py @@ -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: diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py index a9ac6d0..79298b4 100644 --- a/tests/sdbg/test_mcp_adapter.py +++ b/tests/sdbg/test_mcp_adapter.py @@ -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') diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index 02d939b..428f4ab 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -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) diff --git a/toolchain/sdbg/dap.py b/toolchain/sdbg/dap.py index 514f471..7c7b549 100644 --- a/toolchain/sdbg/dap.py +++ b/toolchain/sdbg/dap.py @@ -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 = [] diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py index 549cd5a..e108328 100644 --- a/toolchain/sdbg/mcp_adapter.py +++ b/toolchain/sdbg/mcp_adapter.py @@ -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}) diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index 981b957..644ebde 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -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) diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py index a2bb943..98c607b 100644 --- a/toolchain/sdbg_mcp.py +++ b/toolchain/sdbg_mcp.py @@ -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):