Files
Sprinter-SDCC/tests/sdbg/run_vscode_dap_probe.py
2026-09-17 22:57:27 +03:00

436 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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_GENERATION_MUTATIONS, rpc_call as raw_rpc_call
from sdbg.session import SessionError
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('--socket', default=None,
help='фиксированный Unix socket DAP/MCP-сеанса')
parser.add_argument('--app-hdd', default=None)
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('--screen-mcp-python', default=None,
help='Python с MCP SDK 2.x для снимка hello во время getchar')
parser.add_argument('--screen-proof', default=None,
help='сохранить PNG работающего hello вне временной сессии')
parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
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.socket:
launch['socket'] = options.socket
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)
session_identity = raw_rpc_call(launched['body']['socket'], 'status')
def session_rpc(path, method, arguments=None, timeout=10):
identity = {'session_id': session_identity['session_id'],
'build_id': session_identity['build_id']}
if method in RPC_GENERATION_MUTATIONS:
current = raw_rpc_call(path, 'status', **identity)
identity['generation'] = current['generation']
return raw_rpc_call(path, method, arguments, timeout, **identity)
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.mcp_python:
source = str(ROOT/'tests/hello/hello.c')
send(process, 5, 'setBreakpoints', {
'source': {'path': source}, 'breakpoints': [{'line': 31}]})
points, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10)
dap_point = points['body']['breakpoints'][0]
if not dap_point['verified']:
raise RuntimeError('DAP-точка строки 31 не подтверждена')
probe = subprocess.run([
options.mcp_python, str(ROOT/'tests/sdbg/run_mcp_sdk_probe.py'),
'--socket', launched['body']['socket'], '--source', source,
'--foreign-id', str(dap_point['id'])],
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 = session_rpc(launched['body']['socket'], 'break_line', {
'file': source, 'line': 62, 'owner': owner})
session_rpc(launched['body']['socket'], 'claim_control', {'owner': owner})
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
deadline = time.monotonic()+36
while time.monotonic() < deadline:
events = session_rpc(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:
session_rpc(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)
if not stopped:
raise RuntimeError('После MCP DAP-точка строки 31 не сработала')
send(process, 7, 'stackTrace')
at_point, buffer, _ = wait_response(process, buffer, 'stackTrace', 10)
if at_point['body']['stackFrames'][0]['line'] != 31:
raise RuntimeError('После MCP ожидалась DAP-точка строки 31')
send(process, 8, 'setBreakpoints', {
'source': {'path': source}, 'breakpoints': []})
_, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10)
send(process, 9, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10)
stale, buffer = wait_event(process, buffer, 'stopped', 3)
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:
deadline = time.monotonic()+10
while Path(options.socket).exists() and time.monotonic() < deadline:
time.sleep(.1)
if Path(options.socket).exists():
raise RuntimeError('После DAP disconnect остался Unix socket сессии')
return 0
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))
socket_path = launched['body']['socket']
clear_point = None
if options.clear_while_running:
clear_point = session_rpc(socket_path, 'break_line', {
'file': str(ROOT/'tests/hello/hello.c'), 'line': 63,
'owner': 'probe:running-clear'})
started = time.monotonic()
send(process, 8, 'next')
_, buffer, _ = wait_response(process, buffer, 'next', 3)
response_ms = round((time.monotonic()-started)*1000, 2)
if options.screen_mcp_python:
time.sleep(.6)
screen_command = [options.screen_mcp_python,
str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'),
'--socket', socket_path]
if options.screen_proof:
screen_command += ['--proof', options.screen_proof]
screen_probe = subprocess.run(screen_command, capture_output=True,
text=True, timeout=20, check=False)
if screen_probe.returncode:
raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:])
print(screen_probe.stdout.strip(), flush=True)
if clear_point:
snapshot = session_rpc(socket_path, 'snapshot', timeout=5)
if snapshot['state'] != 'running':
raise RuntimeError('CPU не выполняется перед очисткой точки')
try:
session_rpc(socket_path, 'read_memory', {'address': 0, 'length': 1})
raise RuntimeError('Чтение памяти разрешено при running CPU')
except SessionError as error:
if 'остановленного CPU' not in str(error):
raise
session_rpc(socket_path, 'clear_breakpoint', {
'id': clear_point['id'], 'owner': 'probe:running-clear'})
if '--emulated-key' in sys.argv:
first = session_rpc(socket_path, 'snapshot', timeout=5)
time.sleep(1)
second = session_rpc(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')
session_rpc(socket_path, 'input_key',
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
time.sleep(.15)
session_rpc(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 = session_rpc(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,
'running_clear_verified': bool(clear_point)},
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 = session_rpc(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 = session_rpc(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)