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
+7 -5
View File
@@ -280,16 +280,18 @@ Quick wins:
## Прочий backlog ## Прочий backlog
- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый - [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый
stdio-адаптер с 16 инструментами использует общую sdbg-сессию и stdio-адаптер с 18 инструментами использует общую sdbg-сессию и
owner ID личных точек. Полный план и матрица всех 30 raw-возможностей — owner ID личных точек. Полный план и матрица всех 30 raw-возможностей —
[mcp-convergence-plan.md](mcp-convergence-plan.md). Следующее: [mcp-convergence-plan.md](mcp-convergence-plan.md). Следующее:
эксклюзивный control lease для control lease для DAP/MCP и cleanup точек по истечении heartbeat
DAP/MCP, heartbeat/очистка точек при аварийном выходе MCP, безопасные проверены живым прогоном. Далее проверка
session ID изменяющих команд, безопасные
input/screenshot/console API через session server, затем доказанные input/screenshot/console API через session server, затем доказанные
watchpoints, запись typed values, disassemble_src и batch-загрузка watchpoints, запись typed values, disassemble_src и batch-загрузка
точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же
MAME в обход общей сессии; старый `mame_bridge.lua` удалить лишь после MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT
проверки 16 общих команд и обновления ссылок. после сверки 16 общих команд и живых MCP-прогонов; доказательства — в
[mcp-convergence-plan.md](mcp-convergence-plan.md).
Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer
SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения
raw и C-адаптера разделены. raw и C-адаптера разделены.
+7 -4
View File
@@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени
проверяет сигнатуру entry и запускает session server. Закрытие DAP проверяет сигнатуру entry и запускает session server. Закрытие DAP
завершает только созданные им server/MAME, удаляет временный каталог и завершает только созданные им server/MAME, удаляет временный каталог и
свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс). свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс).
- `sdbg_mcp.py` предоставляет 16 инструментов через официальный MCP SDK - `sdbg_mcp.py` предоставляет 18 инструментов через официальный MCP SDK
2.x поверх того же session server: статус, C-позиция, регистры, logical 2.x поверх того же session server: статус, C-позиция, регистры, logical
memory, переменные, события/логи, личные точки и команды исполнения. memory, переменные, события/логи, личные точки и команды исполнения.
MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном
@@ -284,6 +284,9 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`:
| Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `0x42c9` | | 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 | | 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 | | 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 | | 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` | | 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 не участвует | | Ошибочная 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 и строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
позже новых `mame.arm-*.ips` не появилось. позже новых `mame.arm-*.ips` не появилось.
Далее по плану: арбитраж управления CPU и lease/cleanup для MCP-точек при Далее по плану: завершить арбитраж управления CPU и cleanup для MCP-точек при
аварийном выходе клиента, затем безопасный restart и расширенные выражения. аварийном выходе клиента, затем безопасный restart и расширенные выражения.
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с новым Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с
`-plugin mamebridge` либо старым `mame_bridge.lua`) нельзя просто перенести: они должны `-plugin mamebridge`) нельзя просто перенести: они должны
проходить через общий session server и получить проверенный контракт для проходить через общий session server и получить проверенный контракт для
ввода, скриншотов, записи/watchpoints. VSIX уже упакован; ввода, скриншотов, записи/watchpoints. VSIX уже упакован;
для IDE ещё нужны Run-команда, выбор профиля для IDE ещё нужны Run-команда, выбор профиля
+7 -5
View File
@@ -536,12 +536,14 @@ console_log и загрузка набора точек. Возвращать bu
в арбитраж, а не обходят его. в арбитраж, а не обходят его.
Первый stdio MCP-адаптер уже работает через общий session server и отдаёт Первый stdio MCP-адаптер уже работает через общий session server и отдаёт
16 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой 18 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой
DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния,
точки и последующее срабатывание DAP-точки. Эксклюзивный control lease, точки и последующее срабатывание DAP-точки. Добавлен control lease для
cleanup при аварийном выходе MCP и остальные интерфейсы этого раздела ещё команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat
не реализованы. Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md); проверены живым прогоном;
проверка legacy Lua и перенос всех raw MCP-возможностей в общую сессию — остальные интерфейсы этого раздела ещё не реализованы.
Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md);
результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
[mcp-convergence-plan.md](mcp-convergence-plan.md). [mcp-convergence-plan.md](mcp-convergence-plan.md).
### 8.2 DAP MVP и развитие ### 8.2 DAP MVP и развитие
+33 -4
View File
@@ -8,10 +8,10 @@
проверку C-карты, банки, события и владение точками. Один процесс MAME не проверку C-карты, банки, события и владение точками. Один процесс MAME не
загружает одновременно `mamebridge` и `sdbgbridge`. загружает одновременно `mamebridge` и `sdbgbridge`.
Сейчас `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30 `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
raw-инструментов. Старый `src/mame_bridge.lua` использует тот же Python raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот
frontend, но обрабатывает только 16 команд и не отвечает при hard-stop. же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop.
`toolchain/sdbg_mcp.py` публикует 16 C-инструментов через общую с DAP сессию. `toolchain/sdbg_mcp.py` публикует 18 C-инструментов через общую с DAP сессию.
**Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не **Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не
механически скопировать имена и небезопасную семантику.** Полный raw MCP механически скопировать имена и небезопасную семантику.** Полный raw MCP
остаётся отдельным режимом для задач без C-пакета и других машин MAME. остаётся отдельным режимом для задач без C-пакета и других машин MAME.
@@ -60,6 +60,20 @@ C-MCP, ограничения совместной работы с DAP и жив
проверяемых ссылок на удалённый script в текущем способе запуска нет. До проверяемых ссылок на удалённый script в текущем способе запуска нет. До
этого шага считать legacy устаревшим, но не удалённым. этого шага считать 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-сессию к расширению ## Б. Подготовить общую C-сессию к расширению
1. Ввести control lease между DAP и MCP для `continue`, `pause`, шагов, 1. Ввести control lease между DAP и MCP для `continue`, `pause`, шагов,
@@ -82,6 +96,21 @@ C-MCP, ограничения совместной работы с DAP и жив
**Выход Б:** два 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 по группам ## В. Перенести 30 возможностей raw MCP по группам
| Raw-инструменты | Реализация в общей сессии и проверка | | Raw-инструменты | Реализация в общей сессии и проверка |
+18 -12
View File
@@ -2,11 +2,10 @@
`toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через `toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через
Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же
сессию, карту сборки и журнал, что VS Code/DAP. В репозитории MAME есть две сессию, карту сборки и журнал, что VS Code/DAP. В MAME.HT отдельно работает
Lua-реализации **другого**, raw MCP-моста: `src/mame_bridge.lua` raw MCP-мост `-plugin mamebridge` с `src/mame_mcp.py`. Старый
(`-autoboot_script`) и более новый `-plugin mamebridge`. Оба обслуживают `src/mame_bridge.lua` удалён после проверки: он обрабатывал лишь 16 из 30
один `src/mame_mcp.py` и одинаковый файловый транспорт, но legacy Lua публикуемых Python-команд и не отвечал при остановленном CPU. Plugin использует
обрабатывает только 16 из 30 публикуемых Python-команд. Plugin использует
`register_periodic` и отвечает даже при остановленном CPU. Ни один из этих `register_periodic` и отвечает даже при остановленном CPU. Ни один из этих
вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии. вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии.
`src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x; `src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x;
@@ -48,7 +47,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А
Доступные инструменты: `session_status`, `where`, `read_registers`, Доступные инструменты: `session_status`, `where`, `read_registers`,
`read_memory`, `list_variables`, `read_variable`, `recent_events`, `read_memory`, `list_variables`, `read_variable`, `recent_events`,
`mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`, `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 содержат `pause_execution`, `step_instruction`, `step_source`. Ответы SDK 2.x содержат
`structuredContent`. `step_source` принимает `into`, `over` или `out` и `structuredContent`. `step_source` принимает `into`, `over` или `out` и
возвращает принятие команды; итоговую остановку получите через возвращает принятие команды; итоговую остановку получите через
@@ -71,8 +71,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А
Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить
`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять `mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять
CPU и точки без общего owner ID и журнала. CPU и точки без общего owner ID и журнала.
Поэтапные проверки перед удалением legacy Lua и перенос всех 30 возможностей Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую
в общую C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md). C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md).
Чтение памяти принимает десятичный адрес или `0xHEX`, 1256 байт logical Чтение памяти принимает десятичный адрес или `0xHEX`, 1256 байт logical
Z80 memory, только при остановленном CPU, без side effects. Ответ содержит Z80 memory, только при остановленном CPU, без side effects. Ответ содержит
@@ -86,10 +86,16 @@ generation и страницы банков; диапазон не может п
удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP. удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP.
Удаление личной точки при running CPU отдельно проверено на ожидании Удаление личной точки при running CPU отдельно проверено на ожидании
`getchar()`; оно не ставит CPU на паузу и не блокирует ввод. `getchar()`; оно не ставит CPU на паузу и не блокирует ввод.
Общий session server сериализует команды, но эксклюзивного владельца Общий session server выдаёт 30-секундный control lease первому владельцу
управления CPU пока нет: не посылайте `continue`/`step` одновременно из MCP команды CPU; MCP может захватить/освободить его явно. Фоновый heartbeat MCP
и VS Code. При аварийном завершении MCP его точки могут остаться до конца и опрос событий DAP продлевают активный lease. Конкурентная команда получает
сессии MAME; нужна отдельная lease/cleanup-механика. Несколько одновременно отказ с именем владельца, после штатного закрытия lease освобождается.
При аварийном завершении MCP lease истекает за 30 секунд; сервер удаляет
личные точки и отпускает удерживаемые клавиши. Живой прогон с владельцем без
heartbeat подтвердил удаление его точки и отсутствие ложной остановки после
возобновления DAP; освобождение удерживаемой клавиши пока проверено только
unit-тестом.
Несколько одновременно
запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест. запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест.
Проверка совместной работы с DAP и реальным MAME: Проверка совместной работы с DAP и реальным MAME:
+11 -2
View File
@@ -21,7 +21,8 @@ async def probe(socket: str, source: str, foreign_id: int):
listed = await client.list_tools() listed = await client.list_tools()
names = {tool.name for tool in listed.tools} names = {tool.name for tool in listed.tools}
expected = {'session_status', 'where', 'read_registers', 'read_memory', 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: if not expected <= names:
raise RuntimeError('Не хватает MCP-инструментов: ' + str(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}) memory = await call('read_memory', {'address': hex(location['pc']), 'length': 4})
events = await call('recent_events', {'after': 0}) events = await call('recent_events', {'after': 0})
if not status['build_id'] or location['status'] != 'mapped' or \ if not status['build_id'] or location['status'] != 'mapped' or \
not status['session_id'] or \
registers['registers']['PC'] != location['pc'] or \ registers['registers']['PC'] != location['pc'] or \
len(memory['hex']) != 8 or not events['events']: len(memory['hex']) != 8 or not events['events']:
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory))) 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}) foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
if not foreign.is_error or 'не принадлежит' not in str(foreign.content): if not foreign.is_error or 'не принадлежит' not in str(foreign.content):
raise RuntimeError('MCP не подтвердил защиту чужой точки VS Code') 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', print(json.dumps({'event': 'mcp_shared_session_verified',
'build_id': status['build_id'], 'pc': location['pc'], 'build_id': status['build_id'], 'pc': location['pc'],
'tools': len(names), 'foreign_point_rejected': True, '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) 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('--launch-path', default=None)
parser.add_argument('--mcp-python', default=None, parser.add_argument('--mcp-python', default=None,
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP') 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', parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key') help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
parser.add_argument('--exit-while-stopped', action='store_true', parser.add_argument('--exit-while-stopped', action='store_true',
@@ -152,6 +154,29 @@ def main():
capture_output=True, text=True, timeout=45, check=False) capture_output=True, text=True, timeout=45, check=False)
if probe.returncode: if probe.returncode:
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:]) 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') send(process, 6, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10) _, buffer, _ = wait_response(process, buffer, 'continue', 10)
stopped, buffer = wait_event(process, buffer, 'stopped', 20) stopped, buffer = wait_event(process, buffer, 'stopped', 20)
@@ -170,6 +195,9 @@ def main():
if stale: if stale:
raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента') raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента')
print(probe.stdout.strip(), flush=True) 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') send(process, 10, 'disconnect')
wait_response(process, buffer, 'disconnect', 10) wait_response(process, buffer, 'disconnect', 10)
if options.socket: 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.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
self.client.events(after=4, timeout=12) self.client.events(after=4, timeout=12)
self.assertEqual(self.calls[-1][1:], 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) before = len(self.calls)
with self.assertRaisesRegex(SessionError, 'Адрес памяти'): with self.assertRaisesRegex(SessionError, 'Адрес памяти'):
self.client.read_memory('not-an-address') 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 == 'snapshot': return {'state': self.state}
if command == 'console_print': return {'printed': True} if command == 'console_print': return {'printed': True}
if command == 'memory': return {'hex': '00' * arguments['length']} if command == 'memory': return {'hex': '00' * arguments['length']}
if command == 'key': return {'accepted': True}
raise AssertionError(command) raise AssertionError(command)
@@ -217,6 +218,64 @@ class ServerTests(unittest.TestCase):
finally: finally:
controller.close() 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): def test_read_memory_is_bounded_and_generation_tied(self):
session = DummySession() session = DummySession()
controller = SessionController(session) controller = SessionController(session)
+7 -1
View File
@@ -54,6 +54,11 @@ class DapEngine:
'description': self._description(location)})) 'description': self._description(location)}))
return {}, events return {}, events
if command == 'disconnect': if command == 'disconnect':
if self.attached is not None:
try:
self.rpc('release_control')
except (SessionError, OSError):
pass
return {}, events return {}, events
if command == 'threads': if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
@@ -118,7 +123,8 @@ class DapEngine:
def poll_events(self, timeout=1): def poll_events(self, timeout=1):
previous = self.event_sequence 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) timeout=timeout + 2)
self.event_sequence = result['last'] self.event_sequence = result['last']
translated = [] translated = []
+15 -5
View File
@@ -42,7 +42,8 @@ class McpSession:
def events(self, after: int = 0, timeout: float = 0): def events(self, after: int = 0, timeout: float = 0):
if after < 0 or timeout < 0 or timeout > 30: if after < 0 or timeout < 0 or timeout > 30:
raise SessionError('after должен быть неотрицательным, timeout — от 0 до 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)) timeout=max(10, timeout + 2))
def console_tail(self, count: int = 40): def console_tail(self, count: int = 40):
@@ -63,15 +64,24 @@ class McpSession:
return self.call('clear_owned_breakpoints', {'owner': self.owner}) return self.call('clear_owned_breakpoints', {'owner': self.owner})
def continue_execution(self): def continue_execution(self):
return self.call('continue') return self.call('continue', {'owner': self.owner})
def pause_execution(self): def pause_execution(self):
return self.call('pause') return self.call('pause', {'owner': self.owner})
def step_instruction(self): def step_instruction(self):
return self.call('step') return self.call('step', {'owner': self.owner})
def step_source(self, kind: str = 'into'): def step_source(self, kind: str = 'into'):
if kind not in ('into', 'over', 'out'): if kind not in ('into', 'over', 'out'):
raise SessionError('kind должен быть 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})
+104 -4
View File
@@ -9,6 +9,7 @@ import socketserver
import string import string
import threading import threading
import time import time
import uuid
from .session import DebugSession, SessionError from .session import DebugSession, SessionError
from .transport import BridgeError from .transport import BridgeError
@@ -16,6 +17,8 @@ from .macros import validate_log_message
class SessionController: class SessionController:
CONTROL_TTL = 30.0
def __init__(self, session: DebugSession): def __init__(self, session: DebugSession):
self.session = session self.session = session
self.lock = threading.RLock() self.lock = threading.RLock()
@@ -26,6 +29,11 @@ class SessionController:
self.running = False self.running = False
self.source_step = None self.source_step = None
self.closed = False 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.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = [] self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {} self.breakpoint_info: dict[int, dict] = {}
@@ -84,6 +92,7 @@ class SessionController:
snapshot = self.session.bridge.request('snapshot') snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped': if snapshot['state'] == 'stopped':
if self.running: if self.running:
self._release_all_inputs()
location = self.session.where(snapshot) location = self.session.where(snapshot)
if self.source_step is not None: if self.source_step is not None:
self._source_step_stopped(location) self._source_step_stopped(location)
@@ -104,6 +113,7 @@ class SessionController:
self._emit('invalidated', {'reason': 'reset_or_load'}) self._emit('invalidated', {'reason': 'reset_or_load'})
if not self.running: if not self.running:
next_idle_snapshot = time.monotonic() + .5 next_idle_snapshot = time.monotonic() + .5
self._reap_owners()
except (BridgeError, SessionError, ValueError, OSError) as error: except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False self.running = False
self.source_step = None self.source_step = None
@@ -114,6 +124,11 @@ class SessionController:
def close(self) -> None: def close(self) -> None:
self.closed = True self.closed = True
self.poller.join(timeout=1) self.poller.join(timeout=1)
with self.lock:
try:
self._release_all_inputs()
except (BridgeError, SessionError, OSError):
pass
self.session.bridge.close() self.session.bridge.close()
@staticmethod @staticmethod
@@ -123,8 +138,59 @@ class SessionController:
raise SessionError('owner должен быть непустой строкой до 128 символов') raise SessionError('owner должен быть непустой строкой до 128 символов')
return owner 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: def call(self, method: str, arguments: dict) -> object:
if method == 'events': 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)) after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30) timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
@@ -138,12 +204,32 @@ class SessionController:
with self.lock: with self.lock:
if self.closed: if self.closed:
raise SessionError('Сессия закрыта или инвалидирована') 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', if self.running and method in ('where', 'registers', 'read_memory',
'read_variable', 'break_line', 'break_function'): 'read_variable', 'break_line', 'break_function'):
raise SessionError('Операция требует остановленного CPU; сначала Pause') raise SessionError('Операция требует остановленного CPU; сначала Pause')
if method == 'status': if method == 'status':
return {**self.attached, 'running': self.running, 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': if method == 'snapshot':
return self.session.bridge.request('snapshot') return self.session.bridge.request('snapshot')
if method == 'mame_console_tail': if method == 'mame_console_tail':
@@ -152,9 +238,16 @@ class SessionController:
if method == 'input_key': if method == 'input_key':
if not self.running and arguments['down'] is not False: if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU') raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request( self._control(arguments)
'key', tag=arguments['tag'], mask=int(arguments['mask']), tag, mask = arguments['tag'], int(arguments['mask'])
result = self.session.bridge.request('key', tag=tag, mask=mask,
down=arguments['down']) 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': if method == 'where':
return self.session.where() return self.session.where()
if method == 'registers': if method == 'registers':
@@ -177,6 +270,7 @@ class SessionController:
return self.session.read_variable(arguments['name'], arguments.get('module')) return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line': if method == 'break_line':
owner = self._owner(arguments) owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_line(arguments['file'], int(arguments['line'])) item = self.session.break_line(arguments['file'], int(arguments['line']))
self.breakpoint_info[item['id']] = { self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'], 'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -184,6 +278,7 @@ class SessionController:
return item return item
if method == 'break_function': if method == 'break_function':
owner = self._owner(arguments) owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_function(arguments['name']) item = self.session.break_function(arguments['name'])
self.breakpoint_info[item['id']] = { self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'], 'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -192,7 +287,8 @@ class SessionController:
if method == 'clear_breakpoint': if method == 'clear_breakpoint':
identifier = int(arguments['id']) identifier = int(arguments['id'])
owner = arguments.get('owner') 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('Точка не принадлежит этому клиенту') raise SessionError('Точка не принадлежит этому клиенту')
result = self.session.clear_breakpoint(identifier) result = self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None) self.breakpoint_info.pop(identifier, None)
@@ -217,11 +313,13 @@ class SessionController:
if method == 'continue': if method == 'continue':
if self.running: if self.running:
raise SessionError('CPU уже выполняется; сначала Pause') raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
result = self.session.bridge.request('continue') result = self.session.bridge.request('continue')
self.running = True self.running = True
self._emit('continued', {}) self._emit('continued', {})
return result return result
if method == 'pause': if method == 'pause':
self._control(arguments)
self.source_step = None self.source_step = None
self.session.bridge.request('pause') self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped()) location = self.session.where(self.session.bridge.wait_stopped())
@@ -231,6 +329,7 @@ class SessionController:
if method == 'step': if method == 'step':
if self.running: if self.running:
raise SessionError('CPU уже выполняется; сначала Pause') raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
self.session.bridge.request('step') self.session.bridge.request('step')
self.running = True self.running = True
self._emit('continued', {'reason': 'step'}) self._emit('continued', {'reason': 'step'})
@@ -239,6 +338,7 @@ class SessionController:
self._emit('stopped', {'reason': 'step', 'location': location}) self._emit('stopped', {'reason': 'step', 'location': location})
return location return location
if method == 'source_step': if method == 'source_step':
self._control(arguments)
return self._source_step(arguments.get('kind', 'into')) return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method) raise SessionError('Неизвестный RPC-метод: ' + method)
+30
View File
@@ -6,6 +6,7 @@ import argparse
from functools import wraps from functools import wraps
import os import os
import sys import sys
import threading
from typing import Any from typing import Any
from sdbg.mcp_adapter import McpSession from sdbg.mcp_adapter import McpSession
@@ -102,6 +103,16 @@ def make_server(client: McpSession):
"""Продолжить CPU; событие остановки читать через recent_events.""" """Продолжить CPU; событие остановки читать через recent_events."""
return client.continue_execution() 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() @tool()
def pause_execution() -> dict[str, Any]: def pause_execution() -> dict[str, Any]:
"""Остановить CPU и вернуть текущую C-позицию.""" """Остановить CPU и вернуть текущую C-позицию."""
@@ -128,8 +139,20 @@ def main() -> int:
if not args.socket: if not args.socket:
parser.error('укажите --socket или SDBG_SOCKET') parser.error('укажите --socket или SDBG_SOCKET')
client = McpSession(args.socket) client = McpSession(args.socket)
heartbeat_stop = threading.Event()
heartbeat = None
try: try:
client.status() # Не публикуем инструменты для отсутствующей сессии. 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 = make_server(client)
server.run(transport='stdio') server.run(transport='stdio')
return 0 return 0
@@ -137,6 +160,13 @@ def main() -> int:
print('sdbg-mcp: ' + str(error), file=sys.stderr) print('sdbg-mcp: ' + str(error), file=sys.stderr)
return 1 return 1
finally: finally:
heartbeat_stop.set()
if heartbeat is not None:
heartbeat.join(timeout=1)
try:
client.release_control()
except (SessionError, OSError):
pass
try: try:
client.clear_owned_breakpoints() client.clear_owned_breakpoints()
except (SessionError, OSError): except (SessionError, OSError):