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