#!/usr/bin/env python3 """Живой stdio DAP-проход для проверки VS Code → launcher → MAME. Запускает изолированный MAME и завершает только процессы этого репро. """ from __future__ import annotations import argparse import json import os from pathlib import Path import select import signal import subprocess import sys import time ROOT = Path(__file__).resolve().parents[2] PYTHON = Path.home()/'.pyenv/shims/python' sys.path.insert(0, str(ROOT/'toolchain')) from mame_interactive import resolve from sdbg.server import rpc_call def send(process, sequence, command, arguments=None): body = json.dumps({'seq': sequence, 'type': 'request', 'command': command, 'arguments': arguments or {}}).encode() frame = f'Content-Length: {len(body)}\r\n\r\n'.encode()+body process.stdin.write(frame) process.stdin.flush() def receive(process, buffer, timeout): deadline = time.monotonic()+timeout while True: separator = buffer.find(b'\r\n\r\n') if separator >= 0: header = buffer[:separator] length = None for line in header.split(b'\r\n'): if line.lower().startswith(b'content-length:'): length = int(line.split(b':', 1)[1].strip()) if length is None: raise RuntimeError('DAP frame без Content-Length') end = separator+4+length if len(buffer) >= end: return json.loads(buffer[separator+4:end]), buffer[end:] remaining = deadline-time.monotonic() if remaining <= 0: raise TimeoutError('DAP response timeout') readable, _, _ = select.select([process.stdout], [], [], remaining) if not readable: raise TimeoutError('DAP response timeout') chunk = os.read(process.stdout.fileno(), 65536) if not chunk: raise RuntimeError(f'DAP завершился, rc={process.poll()}') buffer += chunk def wait_response(process, buffer, command, timeout): deadline = time.monotonic()+timeout events = [] while time.monotonic() < deadline: message, buffer = receive(process, buffer, deadline-time.monotonic()) if message.get('type') == 'event': events.append(message) continue if message.get('command') == command: if not message.get('success'): raise RuntimeError(f'{command}: {message.get("message")}') return message, buffer, events raise TimeoutError(command) def wait_event(process, buffer, event, timeout): deadline = time.monotonic()+timeout while time.monotonic() < deadline: try: message, buffer = receive(process, buffer, deadline-time.monotonic()) except TimeoutError: return None, buffer if message.get('event') == 'terminated': raise RuntimeError('DAP session завершилась во время '+event) if message.get('event') == event: return message, buffer return None, buffer def main(): parser = argparse.ArgumentParser(description=__doc__) 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') parser.add_argument('--stop-while-stopped', action='store_true', help='послать DAP disconnect на main и проверить завершение MAME') parser.add_argument('--term-while-stopped', action='store_true', help='послать SIGTERM собственному MAME на main и проверить DAP terminated') options, _ = parser.parse_known_args() if not PYTHON.is_file(): raise RuntimeError('Нет local pyenv shim: '+str(PYTHON)) build = Path(options.build).resolve() if options.build else \ ROOT/'tests/hello/.sprinter-cc-hello' command = [str(PYTHON), str(ROOT/'toolchain/sdbg_dap.py')] process = subprocess.Popen(command, cwd=ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) buffer = b'' try: send(process, 1, 'initialize') _, buffer, _ = wait_response(process, buffer, 'initialize', 10) debugger = 'osx' if '--osx' in sys.argv else 'sdbg' launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30} if options.app_hdd: launch['appHdd'] = str(Path(options.app_hdd).resolve()) if options.launch_path: launch['launchPath'] = options.launch_path send(process, 2, 'launch', launch) launched, buffer, events = wait_response(process, buffer, 'launch', 80) if not any(item.get('event') == 'initialized' for item in events): initialized, buffer = receive(process, buffer, 5) if initialized.get('event') != 'initialized': raise RuntimeError('Нет initialized: '+str(initialized)) send(process, 3, 'configurationDone') _, buffer, _ = wait_response(process, buffer, 'configurationDone', 10) send(process, 4, 'stackTrace') frame_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) frame = frame_response['body']['stackFrames'][0] if frame['name'] != 'main': raise RuntimeError('Не main: '+str(frame)) if options.stop_while_stopped: mame_pid = launched['body']['mamePid'] send(process, 5, 'disconnect') _, buffer, _ = wait_response(process, buffer, 'disconnect', 10) deadline = time.monotonic()+12 while time.monotonic() < deadline: try: os.kill(mame_pid, 0) except ProcessLookupError: break time.sleep(.1) else: raise RuntimeError('После DAP disconnect MAME остался запущен') code = process.wait(timeout=3) if code != 0: raise RuntimeError(f'После DAP disconnect адаптер завершился с rc={code}') print(json.dumps({'event': 'vscode_stop_closes_mame', 'frame': frame}, ensure_ascii=False), flush=True) return 0 if options.term_while_stopped: mame_pid = launched['body']['mamePid'] os.kill(mame_pid, signal.SIGTERM) deadline = time.monotonic()+12 while time.monotonic() < deadline: try: message, buffer = receive(process, buffer, deadline-time.monotonic()) except TimeoutError: break if message.get('event') == 'terminated': print(json.dumps({'event': 'sigterm_terminates_dap', 'frame': frame}, ensure_ascii=False), flush=True) send(process, 5, 'disconnect') wait_response(process, buffer, 'disconnect', 10) return 0 try: os.kill(mame_pid, 0) status = 'alive' except ProcessLookupError: status = 'exited' process_state = subprocess.run( ['ps', '-p', str(mame_pid), '-o', 'stat=,ppid=,comm='], capture_output=True, text=True, check=False).stdout.strip() raise RuntimeError('SIGTERM: DAP terminated не получен за 12 с; MAME '+ status+'; ps='+repr(process_state)) 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')}, 'breakpoints': [{'line': 62}], }) points, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10) if not points['body']['breakpoints'][0]['verified']: raise RuntimeError('Не подтверждена точка getchar') send(process, 6, 'continue') _, buffer, _ = wait_response(process, buffer, 'continue', 10) stopped, buffer = wait_event(process, buffer, 'stopped', 30) if not stopped: raise RuntimeError('Нет остановки перед getchar') send(process, 7, 'stackTrace') at_getchar, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) before = at_getchar['body']['stackFrames'][0] if before['line'] != 62: raise RuntimeError('Остановка не на getchar: '+str(before)) started = time.monotonic() send(process, 8, 'next') _, buffer, _ = wait_response(process, buffer, 'next', 3) response_ms = round((time.monotonic()-started)*1000, 2) if '--emulated-key' in sys.argv: socket_path = launched['body']['socket'] first = rpc_call(socket_path, 'snapshot', timeout=5) time.sleep(1) second = rpc_call(socket_path, 'snapshot', timeout=5) if second['paused'] or second['time'] <= first['time']: raise RuntimeError('MAME не обновляет input frames: '+ str((first, second))) tag, mask, _ = resolve('x') rpc_call(socket_path, 'input_key', {'tag': tag, 'mask': mask, 'down': True}, timeout=5) time.sleep(.15) rpc_call(socket_path, 'input_key', {'tag': tag, 'mask': mask, 'down': False}, timeout=5) key_stop, buffer = wait_event(process, buffer, 'stopped', 10) if not key_stop: raise RuntimeError('PC keyboard ioport не завершил WAITKEY') send(process, 9, 'stackTrace') after_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) after = after_response['body']['stackFrames'][0] if after['line'] != 63: raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) ascii_code = rpc_call(socket_path, 'registers', timeout=5) ascii_code = ascii_code['registers']['DE'] & 0xff if ascii_code != ord('x'): raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') print(json.dumps({'event': 'mame_keyboard_port_verified', 'at': before, 'after': after, 'ascii': ascii_code, 'emulated_time_advanced': second['time']-first['time'], 'next_response_ms': response_ms}, ensure_ascii=False), flush=True) send(process, 10, 'disconnect') wait_response(process, buffer, 'disconnect', 10) return 0 if '--manual-key' in sys.argv: socket_path = launched['body']['socket'] keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards'] if keyboard_state.get(':kbd:ms_naturl') is not True: raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+ str(keyboard_state)) print(json.dumps({'event': 'manual_key_ready', 'mame_pid': launched['body']['mamePid'], 'keyboards': keyboard_state, 'instruction': 'Click this MAME Sprinter window and press x'}, ensure_ascii=False), flush=True) key_stop, buffer = wait_event(process, buffer, 'stopped', 45) if not key_stop: raise RuntimeError('Ручное нажатие x не завершило WAITKEY за 45 секунд') send(process, 9, 'stackTrace') after_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) after = after_response['body']['stackFrames'][0] if after['line'] != 63: raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) ascii_code = rpc_call(socket_path, 'registers', timeout=5) ascii_code = ascii_code['registers']['DE'] & 0xff if ascii_code != ord('x'): raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') print(json.dumps({'event': 'physical_mame_key_verified', 'at': before, 'after': after, 'ascii': ascii_code, 'next_response_ms': response_ms}, ensure_ascii=False), flush=True) send(process, 10, 'disconnect') wait_response(process, buffer, 'disconnect', 10) return 0 early, buffer = wait_event(process, buffer, 'stopped', 12) if early: raise RuntimeError('getchar завершился без нового ввода: '+str(early)) send(process, 9, 'pause') _, buffer, pause_events = wait_response(process, buffer, 'pause', 6) if not any(item.get('event') == 'stopped' for item in pause_events): paused, buffer = wait_event(process, buffer, 'stopped', 6) if not paused: raise RuntimeError('Pause недоступен во время WAITKEY') print(json.dumps({'event': 'waitkey_step_remains_responsive', 'at': before, 'next_response_ms': response_ms, 'wait_without_key_s': 12, 'pause': 'verified'}, ensure_ascii=False), flush=True) send(process, 10, 'disconnect') wait_response(process, buffer, 'disconnect', 10) return 0 print(json.dumps({'event': 'vscode_dap_launch_verified', 'entry': launched['body']['entry'], 'frame': frame}, ensure_ascii=False), flush=True) send(process, 5, 'disconnect') wait_response(process, buffer, 'disconnect', 10) return 0 finally: if process.poll() is None: process.terminate() try: _, stderr = process.communicate(timeout=8) except subprocess.TimeoutExpired: process.kill() _, stderr = process.communicate() if stderr: print(stderr.decode(errors='replace')[-4000:], file=sys.stderr) if __name__ == '__main__': try: sys.exit(main()) except (RuntimeError, TimeoutError, OSError, KeyError) as error: print('vscode-dap-probe: '+str(error), file=sys.stderr) sys.exit(1)