diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index 1bb1b42..431c3a9 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -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. Нет автоматического чтения diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index d08a556..6ad107b 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -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-отладка и логи Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md index e5f21b5..a2034d2 100644 --- a/docs/vscode-sprinter-debug.md +++ b/docs/vscode-sprinter-debug.md @@ -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 всё равно обязательно. Перед вводом сохраняется диагностический diff --git a/tests/sdbg/run_vscode_dap_probe.py b/tests/sdbg/run_vscode_dap_probe.py index 3813de0..18ff195 100644 --- a/tests/sdbg/run_vscode_dap_probe.py +++ b/tests/sdbg/run_vscode_dap_probe.py @@ -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')}, diff --git a/tests/sdbg/test_dap.py b/tests/sdbg/test_dap.py index ed168b5..0d759fd 100644 --- a/tests/sdbg/test_dap.py +++ b/tests/sdbg/test_dap.py @@ -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': {}}, diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index e88983c..1f14255 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -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) diff --git a/toolchain/sdbg/dap.py b/toolchain/sdbg/dap.py index 5f93429..514f471 100644 --- a/toolchain/sdbg/dap.py +++ b/toolchain/sdbg/dap.py @@ -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 diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index 499ea88..1899d44 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -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