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
- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый
stdio-адаптер с 16 инструментами использует общую sdbg-сессию и
stdio-адаптер с 18 инструментами использует общую sdbg-сессию и
owner ID личных точек. Полный план и матрица всех 30 raw-возможностей —
[mcp-convergence-plan.md](mcp-convergence-plan.md). Следующее:
эксклюзивный control lease для
DAP/MCP, heartbeat/очистка точек при аварийном выходе MCP, безопасные
control lease для DAP/MCP и cleanup точек по истечении heartbeat
проверены живым прогоном. Далее проверка
session ID изменяющих команд, безопасные
input/screenshot/console API через session server, затем доказанные
watchpoints, запись typed values, disassemble_src и batch-загрузка
точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же
MAME в обход общей сессии; старый `mame_bridge.lua` удалить лишь после
проверки 16 общих команд и обновления ссылок.
MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT
после сверки 16 общих команд и живых MCP-прогонов; доказательства — в
[mcp-convergence-plan.md](mcp-convergence-plan.md).
Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer
SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения
raw и C-адаптера разделены.
+7 -4
View File
@@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени
проверяет сигнатуру entry и запускает session server. Закрытие DAP
завершает только созданные им server/MAME, удаляет временный каталог и
свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс).
- `sdbg_mcp.py` предоставляет 16 инструментов через официальный MCP SDK
- `sdbg_mcp.py` предоставляет 18 инструментов через официальный MCP SDK
2.x поверх того же session server: статус, C-позиция, регистры, logical
memory, переменные, события/логи, личные точки и команды исполнения.
MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном
@@ -284,6 +284,9 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`:
| Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `0x42c9` |
| VS Code stdio DAP launch, 2026-09-15 | Local pyenv shim → DAP → изолированный MAME/DSS → `hello``main:17`, PC=`0x8224`; `false_hits=0`, штатный disconnect |
| MAME.HT 0.289, 2026-09-17 | Чистая сборка `make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp` и отдельные рецепты `stock`/`sdbg` создали `sprinter`; DAP в `sdbg` и `osx` на patched, а также `osx` на stock дождался DSS и остановился в `hello.c:17` без ложных точек. `getchar()` получил `x`, общий DAP/MCP сохранил ownership точек. Raw `mamebridge` через официальный MCP stdio-клиент перечислил 30 инструментов и ответил на `status`/`read_registers` при остановленном CPU; устаревший `resp_1.txt` не подменил ответ после перезапуска frontend |
| Raw MCP plugin/legacy, 2026-09-17 | Все 16 команд удалённого `mame_bridge.lua` найдены в plugin dispatch; MCP stdio-прогоны проверили общий running-набор. Plugin на `hello` прошёл DSS→`main`, запись/восстановление `errno`, реальное срабатывание watchpoint, ввод `x` до строки 63 после `getchar()`, машинные over/out, снимок и пиксели. После удаления legacy-файла оба plugin-пробника повторно прошли |
| Видимый raw MCP `hello`, 2026-09-17 | `tests/sdbg/run_raw_mcp_hello_probe.py` запустил MAME.HT с `-video soft -window`; кадр работающего `hello` показал весь текст и `Press any key to exit...`, затем ввод `x` довёл PC до `hello.c:63` |
| Control lease DAP/MCP, 2026-09-17 | 18 C-инструментов, `claim_control`/`release_control`, 30-секундный lease с heartbeat; живой DAP+MCP-прогон подтвердил передачу управления обратно DAP и попадание в его точку. Второй видимый прогон дождался `owner_expired`, проверил удаление orphan-точки MCP и отсутствие ложной остановки; отпускание клавиш проверено unit-тестом |
| F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62``:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg |
| VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` |
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
@@ -377,10 +380,10 @@ Crash reports в 19:22/19:23 относились к неподдержанно
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
позже новых `mame.arm-*.ips` не появилось.
Далее по плану: арбитраж управления CPU и lease/cleanup для MCP-точек при
Далее по плану: завершить арбитраж управления CPU и cleanup для MCP-точек при
аварийном выходе клиента, затем безопасный restart и расширенные выражения.
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с новым
`-plugin mamebridge` либо старым `mame_bridge.lua`) нельзя просто перенести: они должны
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с
`-plugin mamebridge`) нельзя просто перенести: они должны
проходить через общий session server и получить проверенный контракт для
ввода, скриншотов, записи/watchpoints. VSIX уже упакован;
для IDE ещё нужны Run-команда, выбор профиля
+7 -5
View File
@@ -536,12 +536,14 @@ console_log и загрузка набора точек. Возвращать bu
в арбитраж, а не обходят его.
Первый stdio MCP-адаптер уже работает через общий session server и отдаёт
16 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой
18 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой
DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния,
точки и последующее срабатывание DAP-точки. Эксклюзивный control lease,
cleanup при аварийном выходе MCP и остальные интерфейсы этого раздела ещё
не реализованы. Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md);
проверка legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
точки и последующее срабатывание DAP-точки. Добавлен control lease для
команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat
проверены живым прогоном;
остальные интерфейсы этого раздела ещё не реализованы.
Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md);
результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
[mcp-convergence-plan.md](mcp-convergence-plan.md).
### 8.2 DAP MVP и развитие
+33 -4
View File
@@ -8,10 +8,10 @@
проверку C-карты, банки, события и владение точками. Один процесс MAME не
загружает одновременно `mamebridge` и `sdbgbridge`.
Сейчас `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
raw-инструментов. Старый `src/mame_bridge.lua` использует тот же Python
frontend, но обрабатывает только 16 команд и не отвечает при hard-stop.
`toolchain/sdbg_mcp.py` публикует 16 C-инструментов через общую с DAP сессию.
`MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот
же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop.
`toolchain/sdbg_mcp.py` публикует 18 C-инструментов через общую с DAP сессию.
**Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не
механически скопировать имена и небезопасную семантику.** Полный raw MCP
остаётся отдельным режимом для задач без C-пакета и других машин MAME.
@@ -60,6 +60,20 @@ C-MCP, ограничения совместной работы с DAP и жив
проверяемых ссылок на удалённый script в текущем способе запуска нет. До
этого шага считать legacy устаревшим, но не удалённым.
**Статус А, 2026-09-17 — выполнено.** Сравнение Lua-dispatch показало 16/16
общих команд. Последовательные MCP stdio-прогоны на MAME.HT проверили
регистры, память, дизассемблирование, консоль, точки/watchpoints и
resume/pause у legacy при running CPU и plugin при stop. На реальном `hello`
plugin прошёл DSS→`main`, запись и восстановление `errno`, попадание в
watchpoint, ввод `x` через `getchar()` и машинные шаги over/out. Старый
frame notifier не смог обслужить запрос после service-stop в `main`, что
подтвердило известное ограничение. Файл удалён из активного дерева MAME.HT;
после удаления оба plugin-пробника повторно прошли. Повторный прогон `hello`
с `-video soft -window` показал вывод программы и `Press any key to exit...`
на снимке работающего экрана; затем `x` довёл PC до строки после `getchar()`.
Пробник `tests/sdbg/run_raw_mcp_hello_probe.py` теперь по умолчанию использует
видимое окно, а `--proof` сохраняет снимок вне временного каталога сессии.
## Б. Подготовить общую C-сессию к расширению
1. Ввести control lease между DAP и MCP для `continue`, `pause`, шагов,
@@ -82,6 +96,21 @@ C-MCP, ограничения совместной работы с DAP и жив
**Выход Б:** два MCP-клиента и DAP читают одну сессию; конкурентные мутации
сериализованы, а потеря клиента не оставляет его точек или нажатых клавиш.
**Промежуточный статус Б, 2026-09-17.** В session server добавлен 30-секундный
control lease: команды CPU и ввод принимаются от текущего владельца, чужие
получают явный отказ. MCP предоставляет `claim_control`/`release_control` и
продлевает lease фоновым heartbeat; DAP продлевает его при опросе событий и
освобождает при disconnect. Каждый процесс session server выдаёт новый
`session_id`. Unit-тест проверил конфликт, освобождение и истечение lease;
живой DAP+MCP-прогон проверил передачу управления обратно VS Code и попадание
в его точку. Сервер теперь также удаляет личные MCP-точки и отпускает
удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем
без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие
ложной остановки в `hello.c:62` после возобновления DAP.
**Этап Б не закрыт:** ещё нужны защита
всех мутаций, проверка session ID в каждом изменяющем запросе и безопасная
invalidation при reset/load.
## В. Перенести 30 возможностей raw MCP по группам
| Raw-инструменты | Реализация в общей сессии и проверка |
+18 -12
View File
@@ -2,11 +2,10 @@
`toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через
Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же
сессию, карту сборки и журнал, что VS Code/DAP. В репозитории MAME есть две
Lua-реализации **другого**, raw MCP-моста: `src/mame_bridge.lua`
(`-autoboot_script`) и более новый `-plugin mamebridge`. Оба обслуживают
один `src/mame_mcp.py` и одинаковый файловый транспорт, но legacy Lua
обрабатывает только 16 из 30 публикуемых Python-команд. Plugin использует
сессию, карту сборки и журнал, что VS Code/DAP. В MAME.HT отдельно работает
raw MCP-мост `-plugin mamebridge` с `src/mame_mcp.py`. Старый
`src/mame_bridge.lua` удалён после проверки: он обрабатывал лишь 16 из 30
публикуемых Python-команд и не отвечал при остановленном CPU. Plugin использует
`register_periodic` и отвечает даже при остановленном CPU. Ни один из этих
вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии.
`src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x;
@@ -48,7 +47,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А
Доступные инструменты: `session_status`, `where`, `read_registers`,
`read_memory`, `list_variables`, `read_variable`, `recent_events`,
`mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`,
`clear_breakpoint`, `clear_my_breakpoints`, `continue_execution`,
`clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`,
`continue_execution`,
`pause_execution`, `step_instruction`, `step_source`. Ответы SDK 2.x содержат
`structuredContent`. `step_source` принимает `into`, `over` или `out` и
возвращает принятие команды; итоговую остановку получите через
@@ -71,8 +71,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А
Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить
`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять
CPU и точки без общего owner ID и журнала.
Поэтапные проверки перед удалением legacy Lua и перенос всех 30 возможностей
в общую C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md).
Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую
C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md).
Чтение памяти принимает десятичный адрес или `0xHEX`, 1256 байт logical
Z80 memory, только при остановленном CPU, без side effects. Ответ содержит
@@ -86,10 +86,16 @@ generation и страницы банков; диапазон не может п
удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP.
Удаление личной точки при running CPU отдельно проверено на ожидании
`getchar()`; оно не ставит CPU на паузу и не блокирует ввод.
Общий session server сериализует команды, но эксклюзивного владельца
управления CPU пока нет: не посылайте `continue`/`step` одновременно из MCP
и VS Code. При аварийном завершении MCP его точки могут остаться до конца
сессии MAME; нужна отдельная lease/cleanup-механика. Несколько одновременно
Общий session server выдаёт 30-секундный control lease первому владельцу
команды CPU; MCP может захватить/освободить его явно. Фоновый heartbeat MCP
и опрос событий DAP продлевают активный lease. Конкурентная команда получает
отказ с именем владельца, после штатного закрытия lease освобождается.
При аварийном завершении MCP lease истекает за 30 секунд; сервер удаляет
личные точки и отпускает удерживаемые клавиши. Живой прогон с владельцем без
heartbeat подтвердил удаление его точки и отсутствие ложной остановки после
возобновления DAP; освобождение удерживаемой клавиши пока проверено только
unit-тестом.
Несколько одновременно
запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест.
Проверка совместной работы с DAP и реальным MAME:
+11 -2
View File
@@ -21,7 +21,8 @@ async def probe(socket: str, source: str, foreign_id: int):
listed = await client.list_tools()
names = {tool.name for tool in listed.tools}
expected = {'session_status', 'where', 'read_registers', 'read_memory',
'recent_events', 'set_line_breakpoint', 'clear_breakpoint'}
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
'claim_control', 'release_control'}
if not expected <= names:
raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names))
@@ -39,6 +40,7 @@ async def probe(socket: str, source: str, foreign_id: int):
memory = await call('read_memory', {'address': hex(location['pc']), 'length': 4})
events = await call('recent_events', {'after': 0})
if not status['build_id'] or location['status'] != 'mapped' or \
not status['session_id'] or \
registers['registers']['PC'] != location['pc'] or \
len(memory['hex']) != 8 or not events['events']:
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
@@ -47,10 +49,17 @@ async def probe(socket: str, source: str, foreign_id: int):
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
if not foreign.is_error or 'не принадлежит' not in str(foreign.content):
raise RuntimeError('MCP не подтвердил защиту чужой точки VS Code')
lease = await call('claim_control')
if not lease['owner'].startswith('mcp:'):
raise RuntimeError('MCP не захватил управление CPU: ' + repr(lease))
released = await call('release_control')
if released['released'] != lease['owner']:
raise RuntimeError('MCP не освободил управление CPU: ' + repr(released))
print(json.dumps({'event': 'mcp_shared_session_verified',
'build_id': status['build_id'], 'pc': location['pc'],
'tools': len(names), 'foreign_point_rejected': True,
'owned_point_for_cleanup': own['id']},
'owned_point_for_cleanup': own['id'],
'control_claim_release': True},
ensure_ascii=False), flush=True)
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Живой raw MCP-пробник на C-приложении hello и подготовленной DSS-среде."""
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
ROOT = Path(__file__).resolve().parents[2]
MAME_REPO = ROOT.parent / "MAME.HT"
sys.path.insert(0, str(ROOT / "toolchain"))
from make_disk import create_floppy_image # noqa: E402
from mame_interactive import basename_83, build_events # noqa: E402
from mame_profile import write_keyboard_config # noqa: E402
from sdbg.image import read_ihx # noqa: E402
from sdbg.model import DebugMap # noqa: E402
from sdbg_launcher import lua_script # noqa: E402
def answer_text(result) -> str:
return "".join(item.text for item in result.content if item.type == "text")
async def call(session: ClientSession, name: str, arguments=None) -> str:
result = await session.call_tool(name, arguments or {})
value = answer_text(result)
if result.isError or value.startswith("ERROR:"):
raise AssertionError(f"{name}: {value}")
return value
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--show-seconds", type=float, default=0,
help="Сколько секунд оставить видимым экран hello перед вводом x")
parser.add_argument("--proof", type=Path,
help="Сохранить снимок работающего hello вне временной сессии")
args = parser.parse_args()
home = Path(os.environ["MAME_HOME"]).resolve()
binary = Path(os.environ.get("MAME_BIN", MAME_REPO / "sprinter")).resolve()
rompath = Path(os.environ.get("MAME_ROMPATH", home / "roms")).resolve()
package = ROOT / "tests/hello/.sprinter-cc-hello"
model = DebugMap(package)
model.verify_executable()
executable = Path(model.manifest["executable_path"])
function = next(item for item in model.functions if item["name"] == "main")
main_address = function["start"]
image = read_ihx(package / (executable.stem + ".ihx"))
signature = bytes(image[addr] for addr in range(
function["start"], min(function["end"], function["start"] + 24)))
variable = next(item for item in model.variables if item["name"] == "errno")
raw_address = 0x10000 + variable["logical_address"]
with tempfile.TemporaryDirectory(prefix="sprinter-raw-hello-") as directory:
state = Path(directory)
for name in ("ipc", "cfg", "nvram", "diff", "snapshot"):
(state / name).mkdir()
write_keyboard_config(state / "cfg")
shutil.copyfile(home / "IMG/sp_hdd_sys.chd", state / "system.chd")
disk = state / "debug.img"
if not create_floppy_image(str(disk),
[(basename_83(executable.name), str(executable))]):
raise RuntimeError("Не удалось создать тестовую дискету")
ready = state / "main.json"
lua = state / "launch.lua"
events = build_events([(0, "a:\\" + basename_83(executable.name).replace(" ", "") + "\n")])
lua_script(lua, ready, main_address, signature, events, 0, 30)
env = dict(os.environ, MAME_MCP_DIR=str(state / "ipc"),
MAME_MCP_SNAP_DIR=str(state / "snapshot"),
SDL_NO_SIGNAL_HANDLERS="1")
command = [str(binary), "sprinter", "-noreadconfig", "-rompath", str(rompath),
"-bios", "v3.06", "-kbd", "ms_naturl,bios=sp2k",
"-video", "soft", "-window", "-sound", "none", "-skip_gameinfo",
"-beta:wd179x:0", "35hd", "-beta:wd179x:1", "35hd",
"-flop1", str(disk), "-flop2", str(home / "IMG/dss171u.img"),
"-hard1", str(state / "system.chd"),
"-debug", "-debugger", "sdbg", "-plugin", "mamebridge",
"-pluginspath", str(MAME_REPO / "plugins"),
"-autoboot_delay", "0", "-autoboot_script", str(lua)]
for name in ("cfg", "nvram", "diff", "snapshot"):
command.extend(["-" + name + "_directory", str(state / name)])
log_path = state / "mame.log"
with log_path.open("w") as log:
machine = subprocess.Popen(command, cwd=state, env=env,
stdout=log, stderr=subprocess.STDOUT)
try:
params = StdioServerParameters(
command=sys.executable, args=[str(MAME_REPO / "src/mame_mcp.py")],
env=env)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
deadline = time.monotonic() + 75
await session.initialize()
while not ready.exists():
if machine.poll() is not None:
raise RuntimeError("MAME завершился до main")
if (state / "main.json.error").exists():
raise RuntimeError((state / "main.json.error").read_text())
if time.monotonic() > deadline:
raise TimeoutError("MAME не дошёл до main")
await asyncio.sleep(0.1)
status = await call(session, "status")
if not status.startswith("state=stop"):
raise AssertionError(f"Ожидалась остановка в main: {status}")
logical = await call(session, "read_logical_memory",
{"address": hex(variable["logical_address"]),
"length": 1})
original = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if logical.strip() != original.strip():
raise AssertionError(f"logical/raw memory diverged: {logical}/{original}")
changed = int(original, 16) ^ 1
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": f"{changed:02X}"})
actual = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if actual.strip() != f"{changed:02X}":
raise AssertionError(f"write_memory failed: {actual}")
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": original.strip()})
restored = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if restored.strip() != original.strip():
raise AssertionError("scratch byte not restored")
shares = await call(session, "list_shares")
if "vram" not in shares:
raise AssertionError(f"VRAM share missing: {shares}")
await call(session, "read_vram", {"address": "0", "length": 4})
await call(session, "read_share",
{"name": "vram", "address": "0", "length": 4})
pixels = await call(session, "read_screen_pixels",
{"x": 0, "y": 0, "width": 2, "height": 2})
if not pixels.strip():
raise AssertionError("screen pixels are empty")
await call(session, "list_ports")
await call(session, "disassemble",
{"address": hex(0x10000 + main_address), "num_bytes": 16})
shot = await call(session, "screenshot", {"name": "raw-hello.png"})
if "raw-hello.png" not in shot or not (state / "snapshot/raw-hello.png").is_file():
raise AssertionError(f"screenshot path missing: {shot}")
point = await call(session, "set_watchpoint",
{"address": hex(raw_address), "length": 2,
"access": "w", "space": "program"})
match = re.search(r"Watchpoint (\d+) set", point)
if not match:
raise AssertionError(f"watchpoint ID missing: {point}")
wp_id = int(match.group(1))
await call(session, "resume")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("errno watchpoint did not stop CPU")
await call(session, "clear_watchpoint", {"index": wp_id})
after_getchar = model.line_locations("hello.c", 63)["locations"][0]["logical_address"]
point = await call(session, "set_breakpoint",
{"address": hex(after_getchar)})
match = re.search(r"Breakpoint (\d+) set", point)
if not match:
raise AssertionError(f"breakpoint ID missing: {point}")
bp_id = int(match.group(1))
await call(session, "resume")
await asyncio.sleep(0.5)
running_shot = await call(session, "screenshot",
{"name": "hello-running.png"})
running_image = state / "snapshot/hello-running.png"
if "hello-running.png" not in running_shot or not running_image.is_file():
raise AssertionError(f"working hello screenshot missing: {running_shot}")
if args.proof:
args.proof.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(running_image, args.proof)
print(f"Снимок работающего hello: {args.proof}", flush=True)
if args.show_seconds:
await asyncio.sleep(args.show_seconds)
await call(session, "move_mouse", {"dx": 1, "dy": 0, "frames": 2})
await call(session, "click_mouse", {"button": "left", "frames": 2})
await call(session, "press_input",
{"port": ":JOY1", "mask": "0x400", "frames": 2})
await call(session, "set_input",
{"port": ":JOY1", "mask": "0x400", "value": 1,
"frames": 2})
await call(session, "press_key", {"key": "x", "frames": 3})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("press_key did not leave getchar")
if f"PC=0x{after_getchar:X}" not in status:
raise AssertionError(f"stopped before line 63: {status}")
await call(session, "clear_breakpoint", {"index": bp_id})
await call(session, "step_over", {"count": 1})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_over did not stop")
await call(session, "step_out")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_out did not stop")
await call(session, "resume")
await call(session, "type_string", {"text": "a"})
await call(session, "type_text", {"text": "b"})
print(f"main=0x{main_address:04X}; errno=0x{raw_address:X}; "
f"write/read-back OK; watchpoint={wp_id}; "
f"keyboard→line63=0x{after_getchar:04X}; "
f"pixels={pixels.strip()[:50]}; screenshot={running_shot.strip()}")
except BaseException:
print(log_path.read_text(errors="replace")[-4000:], file=sys.stderr)
raise
finally:
machine.terminate()
machine.wait(timeout=8)
if __name__ == "__main__":
asyncio.run(main())
+28
View File
@@ -96,6 +96,8 @@ def main():
parser.add_argument('--launch-path', default=None)
parser.add_argument('--mcp-python', default=None,
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
parser.add_argument('--orphan-expiry', action='store_true',
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
parser.add_argument('--exit-while-stopped', action='store_true',
@@ -152,6 +154,29 @@ def main():
capture_output=True, text=True, timeout=45, check=False)
if probe.returncode:
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
if options.orphan_expiry:
owner = 'mcp:orphan-expiry-probe'
orphan = rpc_call(launched['body']['socket'], 'break_line', {
'file': source, 'line': 62, 'owner': owner})
rpc_call(launched['body']['socket'], 'claim_control', {'owner': owner})
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
deadline = time.monotonic()+36
while time.monotonic() < deadline:
events = rpc_call(launched['body']['socket'], 'events', {'after': 0})
if any(event['event'] == 'owner_expired' and
event['body']['owner'] == owner for event in events['events']):
break
time.sleep(.5)
else:
raise RuntimeError('MCP owner не истёк за 36 с')
try:
rpc_call(launched['body']['socket'], 'clear_breakpoint', {
'id': orphan['id'], 'owner': owner})
except SessionError as error:
if 'Неизвестная логическая точка' not in str(error):
raise
else:
raise RuntimeError('MCP orphan-точка не была очищена')
send(process, 6, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10)
stopped, buffer = wait_event(process, buffer, 'stopped', 20)
@@ -170,6 +195,9 @@ def main():
if stale:
raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента')
print(probe.stdout.strip(), flush=True)
if options.orphan_expiry:
print(json.dumps({'event': 'mcp_orphan_expired_and_cleared',
'id': orphan['id']}, ensure_ascii=False), flush=True)
send(process, 10, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
if options.socket:
+2 -1
View File
@@ -37,7 +37,8 @@ class McpAdapterTests(unittest.TestCase):
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
self.client.events(after=4, timeout=12)
self.assertEqual(self.calls[-1][1:],
('events', {'after': 4, 'timeout': 12}, 14))
('events', {'after': 4, 'timeout': 12,
'owner': self.client.owner}, 14))
before = len(self.calls)
with self.assertRaisesRegex(SessionError, 'Адрес памяти'):
self.client.read_memory('not-an-address')
+59
View File
@@ -28,6 +28,7 @@ class DummyBridge:
if command == 'snapshot': return {'state': self.state}
if command == 'console_print': return {'printed': True}
if command == 'memory': return {'hex': '00' * arguments['length']}
if command == 'key': return {'accepted': True}
raise AssertionError(command)
@@ -217,6 +218,64 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_control_lease_blocks_other_client_and_can_be_released_or_expire(self):
controller = SessionController(DummySession())
try:
first = controller.call('claim_control', {'owner': 'mcp:a'})
self.assertEqual(first['owner'], 'mcp:a')
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
with self.assertRaisesRegex(SessionError, 'mcp:a'):
controller.call('claim_control', {'owner': 'mcp:b'})
controller.call('renew_control', {'owner': 'mcp:a'})
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
controller.call('release_control', {'owner': 'mcp:b'})
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
controller.call('release_control', {'owner': 'mcp:a'})
self.assertEqual(controller.call('claim_control', {'owner': 'mcp:b'})['owner'], 'mcp:b')
controller.control_deadline = 0
self.assertEqual(controller.call('claim_control', {'owner': 'cli'})['owner'], 'cli')
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
finally:
controller.close()
def test_expired_mcp_owner_clears_points_without_touching_dap_points(self):
session = DummySession()
controller = SessionController(session)
try:
mine = controller.call('break_line', {
'file': '/src/main.c', 'line': 3, 'owner': 'mcp:lost'})
dap = controller.call('break_function', {'name': 'main', 'owner': 'cli'})
controller.call('claim_control', {'owner': 'mcp:lost'})
controller.owner_deadlines['mcp:lost'] = 0
with controller.lock:
controller._reap_owners()
self.assertIn(mine['id'], session.cleared)
self.assertNotIn(dap['id'], session.cleared)
self.assertIsNone(controller.call('status', {})['control_owner'])
self.assertEqual(controller.events[-1]['event'], 'owner_expired')
finally:
controller.close()
def test_expired_owner_releases_held_key(self):
session = DummySession()
controller = SessionController(session)
try:
with controller.lock:
controller.running = True
session.bridge.state = 'running'
controller.call('input_key', {
'owner': 'mcp:lost', 'tag': ':kbd:ms_naturl',
'mask': 0x400, 'down': True})
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': True}), session.bridge.calls)
controller.owner_deadlines['mcp:lost'] = 0
controller._reap_owners()
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': False}), session.bridge.calls)
self.assertFalse(controller.held_inputs)
finally:
controller.close()
def test_read_memory_is_bounded_and_generation_tied(self):
session = DummySession()
controller = SessionController(session)
+7 -1
View File
@@ -54,6 +54,11 @@ class DapEngine:
'description': self._description(location)}))
return {}, events
if command == 'disconnect':
if self.attached is not None:
try:
self.rpc('release_control')
except (SessionError, OSError):
pass
return {}, events
if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
@@ -118,7 +123,8 @@ class DapEngine:
def poll_events(self, timeout=1):
previous = self.event_sequence
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout,
'owner': 'cli'},
timeout=timeout + 2)
self.event_sequence = result['last']
translated = []
+15 -5
View File
@@ -42,7 +42,8 @@ class McpSession:
def events(self, after: int = 0, timeout: float = 0):
if after < 0 or timeout < 0 or timeout > 30:
raise SessionError('after должен быть неотрицательным, timeout — от 0 до 30 с')
return self.call('events', {'after': after, 'timeout': timeout},
return self.call('events', {'after': after, 'timeout': timeout,
'owner': self.owner},
timeout=max(10, timeout + 2))
def console_tail(self, count: int = 40):
@@ -63,15 +64,24 @@ class McpSession:
return self.call('clear_owned_breakpoints', {'owner': self.owner})
def continue_execution(self):
return self.call('continue')
return self.call('continue', {'owner': self.owner})
def pause_execution(self):
return self.call('pause')
return self.call('pause', {'owner': self.owner})
def step_instruction(self):
return self.call('step')
return self.call('step', {'owner': self.owner})
def step_source(self, kind: str = 'into'):
if kind not in ('into', 'over', 'out'):
raise SessionError('kind должен быть into, over или out')
return self.call('source_step', {'kind': kind})
return self.call('source_step', {'kind': kind, 'owner': self.owner})
def claim_control(self):
return self.call('claim_control', {'owner': self.owner})
def renew_control(self):
return self.call('renew_control', {'owner': self.owner})
def release_control(self):
return self.call('release_control', {'owner': self.owner})
+105 -5
View File
@@ -9,6 +9,7 @@ import socketserver
import string
import threading
import time
import uuid
from .session import DebugSession, SessionError
from .transport import BridgeError
@@ -16,6 +17,8 @@ from .macros import validate_log_message
class SessionController:
CONTROL_TTL = 30.0
def __init__(self, session: DebugSession):
self.session = session
self.lock = threading.RLock()
@@ -26,6 +29,11 @@ class SessionController:
self.running = False
self.source_step = None
self.closed = False
self.session_id = uuid.uuid4().hex
self.control_owner: str | None = None
self.control_deadline = 0.0
self.owner_deadlines: dict[str, float] = {}
self.held_inputs: dict[str, set[tuple[str, int]]] = {}
self.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {}
@@ -84,6 +92,7 @@ class SessionController:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
if self.running:
self._release_all_inputs()
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
@@ -104,6 +113,7 @@ class SessionController:
self._emit('invalidated', {'reason': 'reset_or_load'})
if not self.running:
next_idle_snapshot = time.monotonic() + .5
self._reap_owners()
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None
@@ -114,6 +124,11 @@ class SessionController:
def close(self) -> None:
self.closed = True
self.poller.join(timeout=1)
with self.lock:
try:
self._release_all_inputs()
except (BridgeError, SessionError, OSError):
pass
self.session.bridge.close()
@staticmethod
@@ -123,8 +138,59 @@ class SessionController:
raise SessionError('owner должен быть непустой строкой до 128 символов')
return owner
def _control(self, arguments: dict) -> dict:
"""Захватить или продлить управление; чужой живой lease не отбирать."""
owner = self._owner(arguments)
self._touch_owner(owner)
now = time.monotonic()
if self.control_owner is not None and self.control_deadline <= now:
self.control_owner = None
if self.control_owner not in (None, owner):
raise SessionError(f'CPU управляет {self.control_owner}; дождитесь release или истечения lease')
if self.control_owner != owner:
self.control_owner = owner
self._emit('control', {'owner': owner})
self.control_deadline = now + self.CONTROL_TTL
return {'owner': owner, 'expires_in': self.CONTROL_TTL}
def _touch_owner(self, owner: str) -> None:
if owner.startswith('mcp:'):
self.owner_deadlines[owner] = time.monotonic() + self.CONTROL_TTL
def _release_inputs(self, owner: str) -> None:
for tag, mask in self.held_inputs.pop(owner, set()):
self.session.bridge.request('key', tag=tag, mask=mask, down=False)
def _release_all_inputs(self) -> None:
for owner in list(self.held_inputs):
self._release_inputs(owner)
def _reap_owners(self) -> None:
now = time.monotonic()
for owner, deadline in list(self.owner_deadlines.items()):
if deadline > now:
continue
self._release_inputs(owner)
identifiers = [identifier for identifier, info in self.breakpoint_info.items()
if info.get('owner') == owner]
for identifier in identifiers:
self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
self.owner_deadlines.pop(owner, None)
self._emit('owner_expired', {'owner': owner, 'cleared_breakpoints': identifiers})
def call(self, method: str, arguments: dict) -> object:
if method == 'events':
owner = arguments.get('owner')
if owner is not None:
with self.lock:
if self.control_owner == self._owner(arguments) and \
self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout
@@ -138,12 +204,32 @@ class SessionController:
with self.lock:
if self.closed:
raise SessionError('Сессия закрыта или инвалидирована')
if method == 'claim_control':
return self._control(arguments)
if method == 'renew_control':
owner = self._owner(arguments)
self._touch_owner(owner)
if self.control_owner == owner and self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
return {'owner': owner, 'has_control': True,
'expires_in': self.CONTROL_TTL}
return {'owner': owner, 'has_control': False}
if method == 'release_control':
owner = self._owner(arguments)
self._release_inputs(owner)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
return {'released': owner}
if self.running and method in ('where', 'registers', 'read_memory',
'read_variable', 'break_line', 'break_function'):
raise SessionError('Операция требует остановленного CPU; сначала Pause')
if method == 'status':
return {**self.attached, 'running': self.running,
'event_sequence': self.sequence}
'event_sequence': self.sequence, 'session_id': self.session_id,
'control_owner': self.control_owner if
self.control_deadline > time.monotonic() else None}
if method == 'snapshot':
return self.session.bridge.request('snapshot')
if method == 'mame_console_tail':
@@ -152,9 +238,16 @@ class SessionController:
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request(
'key', tag=arguments['tag'], mask=int(arguments['mask']),
down=arguments['down'])
self._control(arguments)
tag, mask = arguments['tag'], int(arguments['mask'])
result = self.session.bridge.request('key', tag=tag, mask=mask,
down=arguments['down'])
held = self.held_inputs.setdefault(self._owner(arguments), set())
if arguments['down']:
held.add((tag, mask))
else:
held.discard((tag, mask))
return result
if method == 'where':
return self.session.where()
if method == 'registers':
@@ -177,6 +270,7 @@ class SessionController:
return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line':
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_line(arguments['file'], int(arguments['line']))
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -184,6 +278,7 @@ class SessionController:
return item
if method == 'break_function':
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_function(arguments['name'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
@@ -192,7 +287,8 @@ class SessionController:
if method == 'clear_breakpoint':
identifier = int(arguments['id'])
owner = arguments.get('owner')
if owner is not None and self.breakpoint_info.get(identifier, {}).get('owner') != owner:
if identifier in self.breakpoint_info and owner is not None and \
self.breakpoint_info[identifier].get('owner') != owner:
raise SessionError('Точка не принадлежит этому клиенту')
result = self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
@@ -217,11 +313,13 @@ class SessionController:
if method == 'continue':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
result = self.session.bridge.request('continue')
self.running = True
self._emit('continued', {})
return result
if method == 'pause':
self._control(arguments)
self.source_step = None
self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped())
@@ -231,6 +329,7 @@ class SessionController:
if method == 'step':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
self.session.bridge.request('step')
self.running = True
self._emit('continued', {'reason': 'step'})
@@ -239,6 +338,7 @@ class SessionController:
self._emit('stopped', {'reason': 'step', 'location': location})
return location
if method == 'source_step':
self._control(arguments)
return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method)
+30
View File
@@ -6,6 +6,7 @@ import argparse
from functools import wraps
import os
import sys
import threading
from typing import Any
from sdbg.mcp_adapter import McpSession
@@ -102,6 +103,16 @@ def make_server(client: McpSession):
"""Продолжить CPU; событие остановки читать через recent_events."""
return client.continue_execution()
@tool()
def claim_control() -> dict[str, Any]:
"""Захватить управление CPU, если оно свободно; конфликт возвращает текущего владельца."""
return client.claim_control()
@tool()
def release_control() -> dict[str, Any]:
"""Освободить своё управление CPU для VS Code или другого MCP-клиента."""
return client.release_control()
@tool()
def pause_execution() -> dict[str, Any]:
"""Остановить CPU и вернуть текущую C-позицию."""
@@ -128,8 +139,20 @@ def main() -> int:
if not args.socket:
parser.error('укажите --socket или SDBG_SOCKET')
client = McpSession(args.socket)
heartbeat_stop = threading.Event()
heartbeat = None
try:
client.status() # Не публикуем инструменты для отсутствующей сессии.
def renew_control():
while not heartbeat_stop.wait(10):
try:
client.renew_control()
except (SessionError, OSError):
# Без lease обновлять нечего; следующая мутация захватит его.
pass
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
daemon=True)
heartbeat.start()
server = make_server(client)
server.run(transport='stdio')
return 0
@@ -137,6 +160,13 @@ def main() -> int:
print('sdbg-mcp: ' + str(error), file=sys.stderr)
return 1
finally:
heartbeat_stop.set()
if heartbeat is not None:
heartbeat.join(timeout=1)
try:
client.release_control()
except (SessionError, OSError):
pass
try:
client.clear_owned_breakpoints()
except (SessionError, OSError):