Завершать DAP при потере остановленного MAME

This commit is contained in:
Александр Петров
2026-09-16 15:12:42 +03:00
parent d25f28e1cd
commit 0a71f94240
8 changed files with 137 additions and 37 deletions
+38 -24
View File
@@ -1,6 +1,6 @@
# Отладка исходников: реализация и результаты
Дата: 2026-09-15. План: [mame-source-debug.md](mame-source-debug.md).
Дата: 2026-09-16. План: [mame-source-debug.md](mame-source-debug.md).
Реализованы сборка/карта, проверенный транспорт, базовая C-сессия,
постоянный session server, DAP MVP, VS Code launch и build task. MCP C-уровня,
безопасный restart и расширенная отладка ещё не готовы.
@@ -65,6 +65,11 @@
логические группы и события. `set_source_breakpoints` и
`set_function_breakpoints` создают новый disabled-набор, при ошибке удаляют
его, затем заменяют прежний набор и активируют точки.
- Session server проверяет `snapshot` и при остановленном CPU раз в 0,5 с:
reset/state load инвалидирует сессию, потеря MAME приводит к закрытию после
ограниченного timeout backend. Если выполнение продолжено из родного окна
debugger MAME, server сообщает DAP смену состояния. Закрытая сессия всегда
выдаёт DAP `terminated`, даже если событие `invalidated` выпало из журнала.
- DAP MVP и минимальное VS Code-расширение: attach к session server,
source/function breakpoints, один достоверный frame, registers,
поддержанные globals/statics, evaluate одного имени, continue/pause и
@@ -80,8 +85,9 @@
для приложений с `app.mk`, команду Build Active Project и автоматическую
debug-сборку перед F5. Она использует `make SRC_DEBUG=1` и local Python 3.12;
ошибки SDCC с файлом/строкой попадают в Problems. Код сборки проверяется
самим расширением: при ошибке MAME не запускается. Run-команда, расширенные
linker/assembler diagnostics, выбор profile/EXTRA_DATA и VSIX остаются
самим расширением: при ошибке MAME не запускается. VSIX 0.2.0 собран и
проверен в отдельном workspace SprPoP. Run-команда, расширенные
linker/assembler diagnostics и выбор profile/EXTRA_DATA остаются
следующими шагами.
- DAP `logMessage` без пересборки: разрешены литералы, `{{`/`}}` и только
подстановки `{variable}`. Session server читает типизированное значение,
@@ -149,9 +155,8 @@ pyenv exec python toolchain/sdbg_session.py \
Доступны `where`, `step`, `continue`, `break-line`, `break-function`,
`read-variable`, `activate-breakpoints` и `deactivate-breakpoints`.
Это диагностический однооперационный CLI: логические ID групп точек живут
только внутри процесса. Постоянное владение и DAP добавятся в общем session
server; до него для долгой ручной работы нужен один Python-процесс с
`DebugSession`.
только внутри процесса. Для долгой работы и DAP используйте общий session
server ниже.
Предпочтительный режим для нескольких клиентов — один server:
@@ -165,7 +170,7 @@ pyenv exec python toolchain/sdbg_client.py \
--socket /tmp/sprinter-sdbg.sock status
```
VS Code-расширение находится в `toolchain/vscode-sprinter-debug/`. В
VS Code-расширение находится в отдельном репозитории `../VSCode-Sprinter`. В
Extension Development Host используется attach-конфигурация:
```json
@@ -177,9 +182,10 @@ Extension Development Host используется attach-конфигурац
}
```
В VS Code расширение `0.2.0` запускает адаптер через абсолютный путь к local
pyenv shim `~/.pyenv/shims/python` и задаёт корень workspace как `cwd`.
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock.
В VS Code расширение `0.2.0` находит SDK через `sprinterDebugger.sdkRoot`,
а Python 3.12 — через local pyenv shim или установленную pyenv-версию.
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock,
и позволяет отлаживать приложение из отдельного workspace.
Выбранные пути видны в Output → `Sprinter MAME Debug`; при раннем сбое
launcher сообщает stderr и последние строки MAME log.
Интегрированный запуск:
@@ -213,6 +219,7 @@ pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --native-debu
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --source-step
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --step-out
pyenv exec python tests/sdbg/run_vscode_dap_probe.py
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --exit-while-stopped
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --manual-key
```
@@ -256,8 +263,16 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`:
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
Полный host-набор: 29 тестов прошли, один Unix-socket тест пропущен только
из-за запрета `bind` в sandbox. Тот же socket path проверен живым DAP-запуском.
Полный host-набор: 39 тестов (38 прошли, один Unix-socket тест пропущен только
из-за запрета `bind` в sandbox). Тот же socket path проверен живым DAP-запуском.
После разделения репозиториев установленный VSIX в изолированном профиле
VS Code запустил сборку SprPoP и остановился в `src/sprpop.c:264` (`main`)
через API extension host. Новый живой пробник закрытия собственного MAME
при остановке в `hello/main` подтверждает DAP `terminated` после внезапной
потери процесса. Штатный DAP launch в `hello/main` также повторно прошёл
с `sdbg` и опциональным `osx` debugger provider.
Graceful-закрытие MAME при остановленном CPU и state load пока не прошли
отдельный живой тест.
Это репро доступности механизма, а не полный benchmark или проверка всех
runtime/mapping/lifecycle. Отдельная публикация debug-библиотек требует
@@ -280,9 +295,8 @@ runtime/mapping/lifecycle. Отдельная публикация debug-биб
подтвердил, что при остановке в `main` окно снова открывается через
Cmd-Tab/Dock. Регрессионный DAP-проход через `getchar()` с клавишей `x`
также завершился на следующей строке с кодом `0x78`.
Воспроизводимый patch и идемпотентный установщик находятся в
`toolchain/mame-patches/` и `toolchain/apply-mame-sdbg-patch.sh`;
`make mame-sdbg` собирает и устанавливает бинарник. Provider `osx` остаётся
Воспроизводимый patch, установщик и сборочные рецепты находятся в отдельном
`../MAME/scripts/sprinter/`. Provider `osx` остаётся
доступной launch-опцией и проверен одновременно с DAP. Для непатченного MAME
можно использовать `auto`: штатные варианты — `osx` на macOS, `windows` в
native Windows build, `qt` или `imgui` в Linux. `qt` зависит от
@@ -299,12 +313,12 @@ host-моста пока не готова: FileBridge использует `fcn
## Размерный регресс и оставшаяся работа
`make size-check` запущен: сообщает рост у 12 программ относительно текущего
эталона, отсутствующие и новые сборки. В списке роста нет пересобранного
hello. Дополнительно cat и hello собраны исходной обёрткой из HEAD и новой
на тех же runtime/библиотеках: EXE попарно идентичны. Эталон не изменялся.
Этот общий check пока не считается прошедшим; расхождение существующих
сборок с baseline отделено от побайтовых регрессий новой debug-сборки.
`make size-check` теперь проходит (46 свежих карт). Прежние 12 расхождений
разобраны: `open()` добавил 9 байт после исправления режимов DSS;
`openenv` содержит ещё новый регрессионный тест; прежний размер `w3bgfx`
был снят по несвежей карте. Подробное сравнение с исходной ревизией и
правила обновления эталона — в
[плане разделения](project-reorganization.md).
Hard reset из RPC был удалён после воспроизводимого падения MAME 0.287:
старый Lua periodic callback обращался к `debugger_manager` во время нового
@@ -316,9 +330,9 @@ Crash reports в 19:22/19:23 относились к неподдержанно
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
позже новых `mame.arm-*.ips` не появилось.
Далее по плану: завершить инвалидацию на exit/state load, MCP-адаптер,
безопасный restart, расширенные выражения
и упаковку VS Code-расширения. Для IDE ещё нужны Run-команда, выбор профиля
Далее по плану: проверить graceful exit/state load живым репро, сделать
MCP-адаптер, безопасный restart и расширенные выражения. VSIX уже упакован;
для IDE ещё нужны Run-команда, выбор профиля
сборки/данных и расширенная диагностика assembler/linker.
Базовый attach уже проверяет принадлежность resident/current-bank к build,
но не умеет читать неотображённую physical RAM. Нет автоматического чтения
+5
View File
@@ -678,6 +678,11 @@ stepIn. Версионировать мост/патчи и установщик
exit/reset/reload инвалидируют состояние; потеря клиента не блокирует MAME,
конкурентные операции не читают смешанный snapshot.
Текущая реализация опрашивает backend и при остановленном CPU: потеря MAME
переводит DAP в `terminated` (проверено живым пробником с внезапным выходом).
Reset/state load обработаны notifier плагина, инвалидация проверена host-тестом;
штатное закрытие MAME и загрузка state требуют отдельных живых проверок.
### Этап 3 — полезная CLI/MCP-отладка и логи
Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись
+6
View File
@@ -99,6 +99,12 @@ service-точка `main` и вводится `a:\\HELLO.EXE`. После сов
а VS Code показывает остановку на entry. Завершение debug session останавливает
только созданные ей MAME/server.
При reset/state load или потере MAME остановленная сессия становится
недействительной и DAP завершает её; для новой отладки запускайте F5 заново.
Проверен сценарий внезапного завершения собственного MAME во время остановки
в `main`. Отдельный живой тест штатного закрытия окна и загрузки state ещё
предстоит выполнить.
`dssTimeout` задаёт предельное время ожидания prompt (30 эмулируемых секунд).
`launchAt` можно задать как необязательную нижнюю границу времени запуска;
наличие prompt всё равно обязательно. Перед вводом сохраняется диагностический
+15
View File
@@ -10,6 +10,7 @@ import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import time
@@ -90,6 +91,8 @@ def main():
parser.add_argument('--build', default=None)
parser.add_argument('--app-hdd', default=None)
parser.add_argument('--launch-path', default=None)
parser.add_argument('--exit-while-stopped', action='store_true',
help='аварийно завершить собственный MAME на main и ждать DAP terminated')
options, _ = parser.parse_known_args()
if not PYTHON.is_file():
raise RuntimeError('Нет local pyenv shim: '+str(PYTHON))
@@ -121,6 +124,18 @@ def main():
frame = frame_response['body']['stackFrames'][0]
if frame['name'] != 'main':
raise RuntimeError('Не main: '+str(frame))
if options.exit_while_stopped:
os.kill(launched['body']['mamePid'], signal.SIGKILL)
deadline = time.monotonic()+15
while time.monotonic() < deadline:
message, buffer = receive(process, buffer, deadline-time.monotonic())
if message.get('event') == 'terminated':
print(json.dumps({'event': 'idle_mame_exit_terminates_dap',
'frame': frame}, ensure_ascii=False), flush=True)
send(process, 5, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
return 0
raise RuntimeError('После закрытия остановленного MAME нет DAP terminated')
if '--waitkey' in sys.argv:
send(process, 5, 'setBreakpoints', {
'source': {'path': str(ROOT/'tests/hello/hello.c')},
+8
View File
@@ -106,6 +106,14 @@ class DapTests(unittest.TestCase):
self.assertIn('пропущено 2', events[0][1]['output'])
self.assertEqual(events[1][1]['output'], 'total=42\n')
def test_closed_session_always_terminates_dap(self):
self.dap._rpc_override = lambda method, args: {
'events': [], 'first': 1, 'last': 0, 'closed': True,
}
events, closed = self.dap.poll_events(timeout=0)
self.assertTrue(closed)
self.assertEqual(events, [('terminated', {'restart': False})])
def test_stdio_framing_without_attach(self):
requests = [
{'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}},
+42 -2
View File
@@ -18,10 +18,13 @@ class DummyModel:
class DummyBridge:
def __init__(self): self.calls = []
def __init__(self):
self.calls = []
self.state = 'stopped'
def close(self): pass
def request(self, command, **arguments):
self.calls.append((command, arguments))
if command == 'snapshot': return {'state': self.state}
if command == 'console_print': return {'printed': True}
raise AssertionError(command)
@@ -37,6 +40,10 @@ class DummySession:
def attach(self):
return {'build_id': 'test', 'location': {'status': 'mapped'}}
def where(self, snapshot=None):
return {'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100,
'sources': [{'file': '/src/main.c', 'line': 3}]}
def break_line(self, filename, line, enabled=True):
if line == 99:
raise SessionError('нет адреса')
@@ -131,6 +138,7 @@ class WaitingBridge:
"""Машинный over ожидает внешний ввод, но pause должен остаться доступен."""
def __init__(self):
self.paused = False
self.started = False
self.calls = []
def close(self): pass
@@ -138,11 +146,12 @@ class WaitingBridge:
def request(self, command, **arguments):
self.calls.append(command)
if command == 'snapshot':
return {'state': 'stopped' if self.paused else 'running'}
return {'state': 'running' if self.started and not self.paused else 'stopped'}
if command == 'pause':
self.paused = True
return {'accepted': True}
if command == 'step_over':
self.started = True
return {'accepted': True}
raise AssertionError(command)
@@ -180,6 +189,37 @@ class ServerTests(unittest.TestCase):
server.close()
controller.close()
def test_idle_snapshot_detects_invalidation(self):
session = DummySession()
controller = SessionController(session)
try:
session.bridge.state = 'invalidated'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: controller.closed,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'invalidated')
self.assertEqual(controller.events[-1]['body']['reason'], 'reset_or_load')
finally:
controller.close()
def test_native_debugger_continue_is_reported(self):
session = DummySession()
controller = SessionController(session)
try:
session.bridge.state = 'running'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: controller.running,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'continued')
self.assertEqual(controller.events[-1]['body']['reason'], 'external')
session.bridge.state = 'stopped'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: not controller.running,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'stopped')
finally:
controller.close()
def test_source_breakpoints_replace_and_rollback(self):
session = DummySession()
controller = SessionController(session)
+2
View File
@@ -140,6 +140,8 @@ class DapEngine:
elif event['event'] == 'output':
translated.append(('output', {'category': event['body'].get('category','console'),
'output': event['body']['output']}))
if result['closed'] and not any(name == 'terminated' for name, _ in translated):
translated.append(('terminated', {'restart': False}))
return translated, result['closed']
@staticmethod
+21 -11
View File
@@ -72,28 +72,38 @@ class SessionController:
self.changed.notify_all()
def _poll(self) -> None:
next_idle_snapshot = 0.0
while not self.closed:
if not self.running:
time.sleep(.02)
continue
delay = next_idle_snapshot - time.monotonic()
if delay > 0:
time.sleep(min(delay, .05))
continue
try:
with self.lock:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
if self.running:
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
elif snapshot['state'] == 'running' and not self.running:
# Команда из родного окна MAME тоже меняет состояние CPU.
self.running = True
self._emit('continued', {'reason': 'external'})
elif snapshot['state'] == 'invalidated':
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': 'reset_or_load'})
if not self.running:
next_idle_snapshot = time.monotonic() + .5
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None