384 lines
20 KiB
Python
384 lines
20 KiB
Python
#!/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
|
||
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('--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)
|
||
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:])
|
||
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)
|
||
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 = rpc_call(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 clear_point:
|
||
snapshot = rpc_call(socket_path, 'snapshot', timeout=5)
|
||
if snapshot['state'] != 'running':
|
||
raise RuntimeError('CPU не выполняется перед очисткой точки')
|
||
try:
|
||
rpc_call(socket_path, 'read_memory', {'address': 0, 'length': 1})
|
||
raise RuntimeError('Чтение памяти разрешено при running CPU')
|
||
except SessionError as error:
|
||
if 'остановленного CPU' not in str(error):
|
||
raise
|
||
rpc_call(socket_path, 'clear_breakpoint', {
|
||
'id': clear_point['id'], 'owner': 'probe:running-clear'})
|
||
if '--emulated-key' in sys.argv:
|
||
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,
|
||
'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 = 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)
|