Verify visible hello MCP run and add shared control lease
This commit is contained in:
+7
-5
@@ -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-адаптера разделены.
|
||||||
|
|||||||
@@ -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-команда, выбор профиля
|
||||||
|
|||||||
@@ -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 и развитие
|
||||||
|
|||||||
@@ -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
@@ -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`, 1–256 байт logical
|
Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
+105
-5
@@ -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'])
|
||||||
down=arguments['down'])
|
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':
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user