Sprinter: добавить отладку C-исходников и интеграцию VS Code

This commit is contained in:
2026-09-15 17:58:41 +03:00
parent 50c6e56b7b
commit e4695b8281
62 changed files with 7147 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
/* Общий inline выявляет коллизии CDB между единицами трансляции. */
static inline unsigned char transform(unsigned char value)
{
return value ^ 0x5a;
}
+5
View File
@@ -0,0 +1,5 @@
#include "common.h"
unsigned char helper(unsigned char value)
{
return transform(value);
}
+2
View File
@@ -0,0 +1,2 @@
static int counter;
int left(void) { counter = 1; return counter; }
+17
View File
@@ -0,0 +1,17 @@
#include <sdbg.h>
volatile int total;
#define AUTHOR_LOG(tag, text) SDBG_LOG(tag, text)
int main(void)
{
total = 1;
AUTHOR_LOG(after_one,
"total=" "{total}");
#if 0
SDBG_LOG(inactive, "should not exist");
#endif
total = 2;
return total;
}
+12
View File
@@ -0,0 +1,12 @@
/* Репро карты строк и типов; global/static должны иметь разные адреса. */
#include "common.h"
int total;
const char *debug_looking_text = "Fmain$text$0_0$0 Lmain.fake";
static signed char counter;
unsigned char helper(unsigned char value);
int main(void)
{
counter = -7;
total = helper(transform(3));
return total + counter;
}
+2
View File
@@ -0,0 +1,2 @@
static int counter;
int right(void) { counter = 2; return counter; }
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Живой DAP-проход: C-макрос → breakpoint → обе console без DSS-вывода."""
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(ROOT/'toolchain'))
from run_vscode_dap_probe import receive, send, wait_response
from sdbg.server import rpc_call
def main():
provider = 'osx' if '--osx' in sys.argv else 'sdbg'
source = ROOT/'tests/sdbg/fixtures/logmacro.c'
with tempfile.TemporaryDirectory(prefix='sprinter-macro-live-') as temp:
exe = Path(temp)/'logmacro.exe'
build = subprocess.run([str(ROOT/'bin/sprinter-cc'), '-o', str(exe),
'--src-debug', str(source)], cwd=ROOT,
env=dict(os.environ, SPRINTER_PYTHON=sys.executable),
capture_output=True, text=True)
if build.returncode:
raise RuntimeError('Не собрано приложение с SDBG_LOG: '+build.stdout+build.stderr)
process = subprocess.Popen([sys.executable, str(ROOT/'toolchain/sdbg_dap.py')], 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)
send(process, 2, 'launch', {'build': str(exe.with_name('.sprinter-cc-logmacro')),
'debugger': provider, 'dssTimeout': 30})
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
if not any(event.get('event') == 'initialized' for event in events):
event, buffer = receive(process, buffer, 5)
if event.get('event') != 'initialized':
raise RuntimeError('Нет DAP initialized: '+str(event))
send(process, 3, 'configurationDone')
_, buffer, _ = wait_response(process, buffer, 'configurationDone', 10)
send(process, 4, 'stackTrace')
frames, buffer, _ = wait_response(process, buffer, 'stackTrace', 10)
if not frames['body']['stackFrames'][0]['name'].startswith('main'):
raise RuntimeError('Перед стартом нет main: '+str(frames['body']['stackFrames']))
send(process, 5, 'continue')
_, buffer, events = wait_response(process, buffer, 'continue', 10)
outputs = [event for event in events if event.get('event') == 'output']
deadline = time.monotonic()+25
while not any('total=1\n' == event.get('body', {}).get('output') for event in outputs):
if time.monotonic() >= deadline:
raise RuntimeError('SDBG_LOG не пришёл в DAP Debug Console: '+str(outputs[-4:]))
event, buffer = receive(process, buffer, deadline-time.monotonic())
if event.get('event') == 'terminated':
raise RuntimeError('DAP завершился до авторского лога')
if event.get('event') == 'output': outputs.append(event)
socket = launched['body']['socket']
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6)
if not any('total=1' in line for line in tail['lines']):
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
print(json.dumps({'event': 'macro_dual_console_verified',
'provider': provider, 'dap_output': 'total=1',
'mame_console': [line for line in tail['lines'] if 'total=1' in line],
'entry': launched['body']['entry']}, ensure_ascii=False), flush=True)
send(process, 6, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
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')[-3000:], file=sys.stderr)
if __name__ == '__main__':
try: main()
except (RuntimeError, TimeoutError, OSError, KeyError, ValueError) as error:
print('macro-log-probe: '+str(error), file=sys.stderr)
sys.exit(1)
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Изолированный MAME-репро: загрузка EXE, проверка main и async step.
Использует копию системного HDD и собственную дискету/каталоги состояния.
Ничего не меняет в общих media или уже запущенном MAME.
"""
import importlib.util
import json
import os
from pathlib import Path
import select
import shutil
import subprocess
import sys
import time
import uuid
# Запуск через системный python3 тоже подхватывает local pyenv проекта.
if sys.version_info < (3, 12):
os.execvp('pyenv', ['pyenv', 'exec', 'python', __file__, *sys.argv[1:]])
ROOT=Path(__file__).resolve().parents[2]
os.environ['SPRINTER_PYTHON'] = sys.executable
sys.path.insert(0,str(ROOT/'toolchain'))
from sdbg.model import DebugMap
from sdbg.image import read_ihx
from sdbg.session import DebugSession
from mame_interactive import build_events
def wait_dap_stop(engine, timeout=15):
deadline=time.monotonic()+timeout
seen=[]
while time.monotonic()<deadline:
events,closed=engine.poll_events(min(.5,deadline-time.monotonic()))
seen.extend(events)
if any(name=='stopped' for name,_ in events): return seen
if closed: break
raise RuntimeError('Исходный шаг не дал stopped: '+str(seen))
def main():
banked_mode = '--banked' in sys.argv
work=ROOT/'build/sdbg-live'
work.mkdir(parents=True,exist_ok=True)
source=work/'probe.c'
source.write_text('''volatile int total;
int main(void) {
total = 42;
while (total) {
total++;
}
return 0;
}
''')
bank_source=work/'probe_bank.c'
if banked_mode:
source.write_text('''volatile int total;
void worker(void) __banked;
int main(void) {
total = 1;
worker();
while (total) { }
return 0;
}
''')
bank_source.write_text('''volatile int bank_value;
void worker(void) __banked {
bank_value = 77;
}
''')
exe=work/'probe.exe'
build=[str(ROOT/'bin/sprinter-cc'),'--src-debug','-o',str(exe)]
if banked_mode:
build.extend(['--memory','huge','--bank','1='+str(bank_source)])
build.append(str(source))
subprocess.run(build,check=True)
model=DebugMap(work/'.sprinter-cc-probe')
function=next(f for f in model.functions if f['name']=='main')
memory=read_ihx(model.directory/'probe.ihx')
signature=[memory[a] for a in range(function['start'],min(function['end'],function['start']+24))]
main_address=function['start']
if '--launcher' in sys.argv:
assert banked_mode,'--launcher probe пока требует --banked'
from sdbg.dap import DapEngine
socket_path=Path('/tmp')/('sprinter-launch-'+uuid.uuid4().hex+'.sock')
launcher_command=[sys.executable,str(ROOT/'toolchain/sdbg_launcher.py'),
'--build',str(model.directory),'--socket',str(socket_path)]
if '--native-debugger' in sys.argv:
launcher_command.extend(['--debugger','osx'])
launcher=subprocess.Popen(launcher_command,
stdout=subprocess.PIPE,stderr=sys.stderr,text=True)
try:
readable,_,_=select.select([launcher.stdout],[],[],75)
assert readable,'Launcher не сообщил ready'
ready=json.loads(launcher.stdout.readline())
assert ready['entry']['false_hits']==0,ready
engine=DapEngine()
engine.handle('attach',{'socket':ready['socket']})
if '--source-step' in sys.argv:
entry_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
engine.handle('stepIn',{})
wait_dap_stop(engine)
into_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
engine.handle('next',{})
wait_dap_stop(engine)
over_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert entry_frame['name'].startswith('main'),entry_frame
assert into_frame['name'].startswith('main'),into_frame
assert over_frame['name'].startswith('main'),over_frame
assert into_frame['line'] != entry_frame['line'],(
entry_frame,into_frame)
assert over_frame['line'] != into_frame['line'],(
into_frame,over_frame)
print(json.dumps({'event':'source_step_verified',
'entry':entry_frame,'into':into_frame,
'over':over_frame,
'dss_ready_time':ready['entry']['dss_ready_time']}))
return 0
engine.handle('setBreakpoints',{'source':{'path':str(bank_source)},
'breakpoints':[{'line':3,'logMessage':'bank_value={bank_value}'}]})
engine.handle('setFunctionBreakpoints',{'breakpoints':[{'name':'worker'}]})
engine.handle('continue',{})
deadline=time.monotonic()+5
stopped=False
dap_events=[]
while time.monotonic()<deadline:
events,closed=engine.poll_events(.5)
dap_events.extend(events)
if any(name=='stopped' for name,_ in events): stopped=True;break
assert stopped
assert any(name=='output' and body['output']=='bank_value=0\n'
for name,body in dap_events),dap_events
frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert frame['name'].startswith('worker'),frame
if '--step-out' in sys.argv:
engine.handle('stepOut',{})
wait_dap_stop(engine)
caller=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert caller['name'].startswith('main'),caller
print(json.dumps({'event':'step_out_verified',
'worker':frame,'caller':caller,
'dss_ready_time':ready['entry']['dss_ready_time']}))
return 0
print(json.dumps({'event':'launcher_verified','entry':ready['entry'],
'build_id':ready['build_id'],'frame':frame['name'],
'logpoint':'bank_value=0'}))
return 0
finally:
if launcher.poll() is None:
launcher.terminate()
try: launcher.wait(timeout=8)
except subprocess.TimeoutExpired: launcher.kill();launcher.wait()
spec=importlib.util.spec_from_file_location('make_disk',ROOT/'mame/v306/make_disk.py')
disk=importlib.util.module_from_spec(spec);spec.loader.exec_module(disk)
disk.create_floppy_image(str(work/'probe.img'),[('PROBE.EXE',str(exe))])
system=work/'system.chd'
if not system.exists(): shutil.copyfile(ROOT/'mame/v306/IMG/sp_hdd_sys.chd',system)
events=build_events([(10,'a:\\probe.exe\n')])
ev=',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events)
report=work/'result.jsonl'
report.write_text('')
lua=work/'probe.lua'
lua.write_text('''if _G.sdbg_probe_loaded then return end
_G.sdbg_probe_loaded=true
local machine=manager.machine
local cpu=machine.devices[":maincpu"]
local debug=machine.debugger
local report=io.open(REPORT,"a")
local function emit(text) report:write(text .. "\\n");report:flush() end
local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end
local events={EVENTS}
local index=1
local signature={SIGNATURE}
local phase="booting"
local last_time=nil
local ticks=0
local bp=nil
local false_hits=0
local launch_reported=false
local active=true
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
-- Первый notifier относится к начальному machine start. После arm это
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
if phase~="booting" then active=false end
end)
debug.execution_state="run"
emu.register_periodic(function()
if not active then return end
ticks=ticks+1
-- До окна запуска приложения DSS работает вообще без наших точек.
-- Первая клавиша launch sequence придёт через полсекунды после arm.
if phase=="booting" and now()>=events[1][1]-0.5 then
bp=cpu.debug:bpset(MAIN,"", "")
phase="loading"
machine.video:snapshot()
emit('{"event":"dss_wait_complete","time":' .. now() .. '}')
emit('{"event":"entry_armed","time":' .. now() .. '}')
end
if not launch_reported and now()>=events[1][1] then
launch_reported=true
emit('{"event":"launch_input_started","time":' .. now() .. '}')
end
while index<=#events and now()>=events[index][1] do
local e=events[index]
for _,field in pairs(machine.ioport.ports[e[2]].fields) do
if field.mask==e[3] then field:set_value(e[4]);break end
end
index=index+1
end
if debug.execution_state=="stop" then
local pc=cpu.state.PC.value
if phase=="loading" then
local match=pc==MAIN
for i,b in ipairs(signature) do
if cpu.spaces.program:read_u8(0x10000+MAIN+i-1)~=b then match=false end
end
if not match then false_hits=false_hits+1;debug.execution_state="run";return end
emit('{"event":"main","pc":' .. pc .. ',"ticks":' .. ticks ..
',"false_hits":' .. false_hits .. '}')
cpu.debug:bpclear(bp)
if os.getenv("SDBG_SESSION_ID") then phase="bridge_wait";return end
phase="stepping"
last_time=now()
cpu.debug:step(1)
emit('{"event":"step_requested","pc":' .. cpu.state.PC.value .. '}')
elseif phase=="stepping" and now()>last_time then
emit('{"event":"step_completed","pc":' .. pc .. ',"ticks":' .. ticks .. '}')
report:close()
machine:exit()
end
end
if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end
end)
'''.replace('REPORT',json.dumps(str(report))).replace('EVENTS',ev)
.replace('SIGNATURE',','.join(map(str,signature))).replace('MAIN',str(main_address)))
command=[str(ROOT/'mame/v306/mame.arm'),'sprinter','-noreadconfig',
'-rompath',str(ROOT/'mame/v306/roms'),'-bios','v3.06','-kbd','ms_naturl,bios=sp2k',
'-video','none','-sound','none','-nothrottle','-skip_gameinfo',
'-beta:wd179x:0','35hd','-flop1',str(work/'probe.img'),
'-hard1',str(system),'-debug','-debugger','none',
'-autoboot_delay','0','-autoboot_script',str(lua)]
for key in ['nvram','cfg','diff','snapshot']:
(work/key).mkdir(exist_ok=True)
command.extend(['-'+key+'_directory',str(work/key)])
bridge_mode = '--bridge' in sys.argv
server_mode = '--server' in sys.argv
env = dict(os.environ)
if bridge_mode:
command[command.index('-debugger')+1] = (
'osx' if '--native-debugger' in sys.argv else 'sdbg')
command[command.index('-video')+1]='soft'
command.append('-window')
ipc=work/('ipc-'+uuid.uuid4().hex)
ipc.mkdir()
env.update(SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=uuid.uuid4().hex)
command.extend(['-verbose','-plugin','sdbgbridge','-pluginspath',
str(ROOT/'toolchain/mcp')+';'+str(ROOT/'mame/sources/MAME/plugins')])
with (work/'mame.log').open('w') as log:
if not bridge_mode:
result=subprocess.run(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT,timeout=55)
else:
from sdbg.transport import FileBridge, BridgeError
process=subprocess.Popen(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT)
bridge=None
server_process=None
try:
deadline=time.monotonic()+50
while '"event":"main"' not in report.read_text():
if process.poll() is not None or time.monotonic()>deadline:
raise RuntimeError('Нет остановки main; см. '+str(work/'mame.log'))
time.sleep(.05)
if server_mode:
assert banked_mode,'--server live probe пока требует --banked'
from sdbg.dap import DapEngine
socket_path=Path('/tmp')/('sprinter-sdbg-'+uuid.uuid4().hex+'.sock')
server_process=subprocess.Popen([
sys.executable,str(ROOT/'toolchain/sdbg_server.py'),
'--build',str(model.directory),'--ipc',str(ipc),
'--session',env['SDBG_SESSION_ID'],'--socket',str(socket_path)],
stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
ready=server_process.stdout.readline()
assert ready and json.loads(ready)['ready'],server_process.stderr.read()
try:
FileBridge(ipc,env['SDBG_SESSION_ID'])
raise AssertionError('Session server не удержал owner lock')
except BridgeError: pass
dap=DapEngine()
attached,_=dap.handle('attach',{'socket':str(socket_path)})
assert attached['buildId']==model.manifest['build_id']
started=time.monotonic()
points,_=dap.handle('setFunctionBreakpoints',
{'breakpoints':[{'name':'worker'}]})
assert points['breakpoints'][0]['verified']
dap.handle('continue',{})
deadline=time.monotonic()+5
dap_events=[]
while time.monotonic()<deadline:
found,closed=dap.poll_events(.5)
dap_events.extend(found)
if any(name=='stopped' for name,_ in dap_events): break
assert any(name=='stopped' for name,_ in dap_events),dap_events
frame=dap.handle('stackTrace',{})[0]['stackFrames'][0]
assert frame['name'].startswith('worker'),frame
location=dap.rpc('where')
assert location['function']['bank']==1
debug_wall_ms=round((time.monotonic()-started)*1000,2)
with report.open('a') as file:
file.write(json.dumps({'event':'server_dap_verified',
'build_id':attached['buildId'],'frame':frame['name'],
'line_break_pc':location['pc'],'bank':1,
'debug_wall_ms':debug_wall_ms})+'\n')
else:
bridge=FileBridge(ipc,env['SDBG_SESSION_ID'])
assert bridge.handshake()['protocol']==1
try:
FileBridge(ipc,env['SDBG_SESSION_ID'])
raise AssertionError('Допущен второй управляющий клиент')
except BridgeError: pass
session=DebugSession(model,bridge)
attached=session.attach()
assert attached['pc']==main_address
assert session.read_variable('total')['value']==0
bridge.generation-=1
try:
bridge.request('memory',address=main_address,length=1)
raise AssertionError('Принята устаревшая generation')
except BridgeError as error:
assert 'generation' in str(error)
try:
bridge.request('clear',id=999999)
raise AssertionError('Удалена чужая точка')
except BridgeError: pass
started=time.monotonic()
if banked_mode:
breakpoint=session.break_function('worker',enabled=False)
expected='ib@e2=='+format(attached['bank_pages'][1],'x')
assert breakpoint['conditions']==[expected]
assert session.activate_breakpoints()['enabled']>=1
bridge.request('continue')
stopped=bridge.wait_stopped()
location=session.where(stopped)
assert location['status'] in ('mapped','ambiguous'), (location,stopped,attached)
assert location['function']['name']=='worker'
assert location['function']['bank']==1
session.clear_breakpoint(breakpoint['id'])
after={'pc':stopped['registers']['PC']}
else:
bridge.request('step')
after=session.where(bridge.wait_stopped())
assert after['pc']==main_address+3
bridge.request('step')
session.where(bridge.wait_stopped())
assert session.read_variable('total')['value']==42
breakpoint=session.break_line(str(source),5)
bridge.request('continue')
stopped=bridge.wait_stopped()
location=session.where(stopped)
assert location['status']=='mapped'
assert location['sources']==[{'file':str(source.resolve()),'line':5}]
session.clear_breakpoint(breakpoint['id'])
events=bridge.request('events',after=0)
assert any(event['kind']=='stopped' for event in events['events'])
final_total=session.read_variable('total')['value'] if not banked_mode else None
debug_wall_ms=round((time.monotonic()-started)*1000,2)
with report.open('a') as file:
file.write(json.dumps({'event':'bridge_verified','pc_before':main_address,
'pc_after_step':after['pc'],
'line_break_pc':stopped['registers']['PC'],
'total':final_total,
'bank':location['function'].get('bank'),
'step_wall_ms':debug_wall_ms,
'generation':bridge.generation})+'\n')
finally:
if bridge: bridge.close()
if server_process and server_process.poll() is None:
server_process.terminate()
try: server_process.wait(timeout=3)
except subprocess.TimeoutExpired: server_process.kill();server_process.wait()
if process.poll() is None:
process.terminate()
try: process.wait(timeout=5)
except subprocess.TimeoutExpired: process.kill();process.wait()
print(report.read_text())
if not bridge_mode and (result.returncode or 'step_completed' not in report.read_text()):
print((work/'mame.log').read_text());return 1
return 0
if __name__=='__main__':sys.exit(main())
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Живой stdio DAP-проход для проверки VS Code → launcher → MAME.
Запускает изолированный MAME и завершает только процессы этого репро.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import select
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():
if not PYTHON.is_file():
raise RuntimeError('Нет local pyenv shim: '+str(PYTHON))
build = 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)
send(process, 2, 'launch', {'build': str(build), 'debugger': 'sdbg',
'dssTimeout': 30})
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 '--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)
+146
View File
@@ -0,0 +1,146 @@
"""DAP MVP сообщает только реализованные возможности и точные адреса."""
from pathlib import Path
import json
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg.dap import DapEngine
from sdbg.session import SessionError
class Rpc:
def __init__(self):
self.calls = []
self.location = {
'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100,
'function': {'name': 'main', 'bank': None},
'sources': [{'file': '/src/main.c', 'line': 3}],
}
def __call__(self, method, arguments):
self.calls.append((method, arguments))
if method == 'status':
return {'build_id': 'abc', 'event_sequence': 4}
if method in ('where', 'pause', 'step', 'source_step'):
return self.location
if method == 'registers':
return {'registers': {'PC': 0x8100, 'SP': 0xbffe}}
if method == 'variables':
return [{'name': 'total', 'module': None, 'type': 'SI:S',
'link_address': 0x8200}]
if method == 'read_variable':
return {'value': 42, 'type': 'SI:S', 'link_address': 0x8200}
if method == 'set_source_breakpoints':
return {'breakpoints': [{'id': 7, 'verified': True, 'line': 3,
'locations': [{'line': 3, 'link_address': 0x8100}]}]}
if method == 'set_function_breakpoints':
return {'breakpoints': [{'id': 8, 'verified': True,
'locations': [{'link_address': 0x8100}]}]}
if method == 'continue':
return {'accepted': True}
if method == 'events':
return {'events': [], 'last': 4, 'closed': False}
raise AssertionError(method)
class DapTests(unittest.TestCase):
def setUp(self):
self.rpc = Rpc()
self.dap = DapEngine(self.rpc)
def call(self, command, **arguments):
return self.dap.handle(command, arguments)
def test_initialize_attach_and_frame(self):
capabilities, events = self.call('initialize')
self.assertTrue(capabilities['supportsFunctionBreakpoints'])
self.assertFalse(capabilities['supportsDisassembleRequest'])
self.assertEqual(events, [])
attached, events = self.call('attach')
self.assertEqual(attached['buildId'], 'abc')
self.assertEqual(events, [('initialized', {})])
stack, _ = self.call('stackTrace')
frame = stack['stackFrames'][0]
self.assertEqual(frame['source']['path'], '/src/main.c')
self.assertEqual(frame['instructionPointerReference'], '0x8100')
def test_breakpoints_variables_and_restricted_evaluate(self):
self.call('attach')
body, _ = self.call('setBreakpoints', source={'path': '/src/main.c'},
breakpoints=[{'line': 3, 'logMessage': 'total={total}'}])
self.assertEqual(body['breakpoints'][0]['id'], 7)
self.assertIn(('set_source_breakpoints', {'file': '/src/main.c',
'breakpoints': [{'line': 3, 'logMessage': 'total={total}'}]}), self.rpc.calls)
variables, _ = self.call('variables', variablesReference=2)
self.assertEqual(variables['variables'][0]['value'], '42')
evaluated, _ = self.call('evaluate', expression='total')
self.assertEqual(evaluated['result'], '42')
with self.assertRaisesRegex(SessionError, 'только имя'):
self.call('evaluate', expression='total+1')
def test_source_and_instruction_steps_use_distinct_rpc(self):
self.call('attach')
self.call('stepIn')
self.assertIn(('source_step', {'kind': 'into'}), self.rpc.calls)
self.call('next')
self.assertIn(('source_step', {'kind': 'over'}), self.rpc.calls)
self.call('stepOut')
self.assertIn(('source_step', {'kind': 'out'}), self.rpc.calls)
body, events = self.call('stepIn', granularity='instruction')
self.assertEqual((body, events), ({}, []))
self.assertIn(('step', {}), self.rpc.calls)
def test_event_ring_gap_warns_debug_console(self):
self.dap.event_sequence = 4
self.dap._rpc_override = lambda method, args: {
'events': [{'seq': 7, 'event': 'output',
'body': {'category': 'console', 'output': 'total=42\n'}}],
'first': 7, 'last': 7, 'closed': False,
}
events, closed = self.dap.poll_events(timeout=0)
self.assertFalse(closed)
self.assertEqual(events[0][0], 'output')
self.assertIn('пропущено 2', events[0][1]['output'])
self.assertEqual(events[1][1]['output'], 'total=42\n')
def test_stdio_framing_without_attach(self):
requests = [
{'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}},
{'seq': 2, 'type': 'request', 'command': 'disconnect', 'arguments': {}},
]
data = b''
for request in requests:
body = json.dumps(request).encode()
data += f'Content-Length: {len(body)}\r\n\r\n'.encode() + body
process = subprocess.run([sys.executable, str(ROOT/'toolchain/sdbg_dap.py')],
input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.assertEqual(process.returncode, 0, process.stderr.decode())
messages = []
output = process.stdout
while output:
header, output = output.split(b'\r\n\r\n', 1)
length = int(header.split(b':', 1)[1])
body, output = output[:length], output[length:]
messages.append(json.loads(body))
self.assertEqual([item['type'] for item in messages],
['response', 'response'])
def test_vscode_extension_activates_before_debug_adapter_factory(self):
extension_dir = ROOT / 'toolchain' / 'vscode-sprinter-debug'
manifest = json.loads((extension_dir / 'package.json').read_text())
self.assertIn('onDebug', manifest['activationEvents'])
self.assertNotIn('onDebug:sprinter-mame', manifest['activationEvents'])
self.assertEqual('auto', manifest['contributes']['configuration']
['properties']['sprinterDebugger.pythonCommand']['default'])
self.assertTrue((extension_dir / manifest['main']).is_file())
debugger_types = {
item['type'] for item in manifest['contributes']['debuggers']
}
self.assertIn('sprinter-mame', debugger_types)
if __name__ == '__main__':
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
"""Генератор launcher Lua не должен менять вложенность событий/сигнатуры."""
from pathlib import Path
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg_launcher import disk_entries, lua_script, write_keyboard_config
class LauncherTests(unittest.TestCase):
def test_lua_tables_have_expected_shape(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'launch.lua'
lua_script(path, Path(directory)/'ready.json', 0x8100, b'\x21\x42',
[(0.0, ':kbd:test', 4, 1)], 0)
text = path.read_text()
self.assertIn('local events={{0.0,":kbd:test",4,1}}', text)
self.assertIn('local signature={33,66}', text)
self.assertIn('local function dss_prompt_row()', text)
self.assertNotIn('local events={{{', text)
self.assertNotIn('local signature={{', text)
def test_disk_entries_reject_missing_and_83_collision(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory)
exe=root/'program-long.exe';exe.write_bytes(b'x')
duplicate=root/'program-last.exe';duplicate.write_bytes(b'y')
with self.assertRaisesRegex(ValueError,'8.3'):
disk_entries(exe,[duplicate])
with self.assertRaisesRegex(ValueError,'Не найден'):
disk_entries(exe,[root/'missing.dat'])
def test_isolated_config_enables_both_sprinter_keyboards(self):
with tempfile.TemporaryDirectory() as directory:
write_keyboard_config(Path(directory))
root = ET.parse(Path(directory)/'sprinter.cfg').getroot()
self.assertEqual(root.attrib['version'], '10')
self.assertEqual(root.find('system').attrib['name'], 'sprinter')
keyboards = {item.attrib['tag']: item.attrib['enabled']
for item in root.findall('./system/input/keyboard')}
self.assertEqual(keyboards, {':': '1', ':kbd:ms_naturl': '1'})
if __name__ == '__main__':
unittest.main()
+231
View File
@@ -0,0 +1,231 @@
"""Регрессии реального SDCC/linker: код, коллизии, банк, stale и ошибки."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
os.environ['SPRINTER_PYTHON'] = sys.executable
sys.path.insert(0, str(ROOT/'toolchain'))
from sdbg.build import normalize
from sdbg.model import DebugMap
class Integration(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix='sdbg-tests-')
cls.work = Path(cls.temp.name)
cls.fixtures = cls.work/'src'
shutil.copytree(ROOT/'tests/sdbg/fixtures', cls.fixtures)
@classmethod
def tearDownClass(cls): cls.temp.cleanup()
def build(self, name, sources, *flags, success=True):
output = self.work/(name+'.exe')
result = subprocess.run([str(ROOT/'bin/sprinter-cc'), '-o', str(output),
*flags, *map(str,sources)], cwd=ROOT,
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if success: self.assertEqual(result.returncode,0,result.stdout)
else: self.assertNotEqual(result.returncode,0,result.stdout)
return output, result
def model(self, name): return DebugMap(self.work/('.sprinter-cc-'+name))
def test_01_raw_collision_and_fixed_byte_identity(self):
sources = [self.fixtures/'main.c', self.fixtures/'helper.c']
sdcc = str(ROOT/'third_party/sdcc/bin/sdcc')
objects=[]
for source in sources:
obj=self.work/(source.stem+'.rel')
subprocess.run([sdcc,'-mz80','--debug','-c','-o',str(obj),str(source)],check=True)
objects.append(str(obj))
raw=subprocess.run([sdcc,'-mz80','--debug','--no-std-crt0','-o',str(self.work/'raw.ihx'),*objects],
text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
self.assertNotEqual(raw.returncode,0)
self.assertIn('Multiple definition of C$',raw.stdout)
plain,_=self.build('plain',sources)
debug,_=self.build('debug',sources,'--src-debug')
self.assertEqual(plain.read_bytes(),debug.read_bytes())
self.assertIn(b'Fmain$text$0_0$0 Lmain.fake', debug.read_bytes())
model=self.model('debug')
matches=[f for f in model.functions if f['name']=='transform']
self.assertEqual(len(matches),2)
self.assertNotEqual(matches[0]['start'],matches[1]['start'])
for f in matches:
self.assertTrue(model.addr2line(f['start'])['sources'])
self.assertEqual(model.addr2line(0xffff)['status'],'unknown')
total=next(v for v in model.variables if v['name']=='total')
counter=next(v for v in model.variables if v['name']=='counter')
self.assertEqual(total['size'],2)
self.assertTrue(counter['signed'])
self.assertEqual(counter['size'],1)
model.verify_executable()
def test_02_banks_and_windows(self):
source=ROOT/'tests/banked'
for mode,base in [('huge',0xc000),('big',0x4000)]:
flags=['--memory',mode,'--bank','1='+str(source/'bank1.c'),
'--bank','2='+str(source/'bank2.c')]
plain,_=self.build(mode+'plain',[source/'banked.c'],*flags)
debug,_=self.build(mode,[source/'banked.c'],'--src-debug',*flags)
self.assertEqual(plain.read_bytes(),debug.read_bytes())
model=self.model(mode)
for bank in (1,2):
function=next(f for f in model.functions if f['name']==f'bank{bank}_func')
self.assertEqual(function['bank'],bank)
self.assertEqual(function['logical_address'],base+12)
# У sprinter_page_w1 в каждом TU существуют начало и epilogue.
locations=model.line_locations('sprinter.h',111)['locations']
self.assertEqual(len(locations),6)
self.assertEqual({l['bank'] for l in locations},{None,1,2})
def test_03_failure_preserves_published_package(self):
source=self.fixtures/'main.c'
sources=[source,self.fixtures/'helper.c']
exe,_=self.build('transaction',sources,'--src-debug')
before=exe.read_bytes()
manifest=(self.work/'.sprinter-cc-transaction/manifest.json').read_bytes()
broken=self.work/'broken.c'
broken.write_text('extern void missing(void); int main(void) { missing(); return 0; }')
_,result=self.build('transaction',[broken],'--src-debug',success=False)
self.assertIn('missing',result.stdout)
self.assertEqual(exe.read_bytes(),before)
self.assertEqual((self.work/'.sprinter-cc-transaction/manifest.json').read_bytes(),manifest)
self.model('transaction').verify_executable()
def test_04_same_basename_and_bank_data(self):
main=self.work/'names.c'
main.write_text('int left(void); int right(void); int main(void) {return left()+right();}')
self.build('names',[main,self.fixtures/'left/utils.c',self.fixtures/'right/utils.c'],'--src-debug')
model=self.model('names')
self.assertEqual(len([f for f in model.functions if f['name'] in ('left','right')]),2)
self.assertEqual(len([v for v in model.variables if v['name']=='counter']),2)
bank=self.work/'data.c'
bank.write_text('volatile int value; void fun(void) __banked {value=42;}')
main.write_text('void fun(void) __banked; int main(void) {fun();return 0;}')
self.build('data',[main],'--src-debug','--memory','big','--bank','1='+str(bank),'--bank-data')
value=next(v for v in self.model('data').variables if v['name']=='value')
self.assertEqual(value['bank'],1)
self.assertEqual(value['window'],1)
def test_05_stale_sources_and_corrupt_artifacts(self):
source=self.work/'stale.c'
source.write_text('int main(void) {return 42;}')
self.build('stale',[source],'--src-debug')
source.write_text('int main(void) {return 0;}')
model=self.model('stale')
self.assertIn(str(source.resolve()),model.stale_sources)
self.assertIn('42',model.source_text(str(source.resolve()),1))
asm=next((self.work/'.sprinter-cc-stale').glob('*.asm'))
asm.write_text('corrupted')
with self.assertRaisesRegex(ValueError,'артефакт'): self.model('stale')
def test_06_selected_tu_and_argument_errors(self):
sources=[self.fixtures/'main.c',self.fixtures/'helper.c']
self.build('selected',sources,'--src-debug-file',str(sources[0]))
self.assertEqual(len(self.model('selected').units),1)
self.build('invalid',sources,'--src-debug-file','nonexistent.c',success=False)
self.build('invalid',sources,'--src-debug','--src-debug-file',str(sources[0]),success=False)
def test_07_make_configuration_and_header_dependency(self):
directory=self.work/'make-project'
shutil.copytree(self.fixtures,directory)
(directory/'Makefile').write_text(
f'PROJ_ROOT := {ROOT}\nEXAMPLE := main\nEXTRA_SRCS := helper.c\n'
f'include {ROOT}/app.mk\n')
def make(*options):
result=subprocess.run(['make','SRC_DEBUG=1','PYTHON='+sys.executable,*options],
cwd=directory,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
self.assertEqual(result.returncode,0,result.stdout)
return result.stdout
self.assertIn('sprinter-cc: wrote',make())
self.assertNotIn('sprinter-cc: wrote',make())
before=json.loads((directory/'.sprinter-cc-main/manifest.json').read_text())['build_id']
header=directory/'common.h'
header.write_text(header.read_text()+'\n/* Правка включённого заголовка. */\n')
self.assertIn('sprinter-cc: wrote',make())
after=json.loads((directory/'.sprinter-cc-main/manifest.json').read_text())['build_id']
self.assertNotEqual(before,after)
self.assertNotIn('sprinter-cc: wrote',make())
self.assertIn('sprinter-cc: wrote',make('SRC_DEBUG=0'))
self.assertNotIn('sprinter-cc: wrote',make('SRC_DEBUG=0'))
def test_09_log_macro_has_linked_anchor_without_exe_growth(self):
source = self.fixtures/'logmacro.c'
plain, _ = self.build('logmacro_plain', [source])
debug, _ = self.build('logmacro_debug', [source], '--src-debug')
self.assertEqual(plain.read_bytes(), debug.read_bytes())
self.assertNotIn(b'total=', debug.read_bytes())
model = self.model('logmacro_debug')
self.assertEqual([item['tag'] for item in model.logpoints], ['after_one'])
anchor = model.logpoints[0]
self.assertTrue(anchor['verified'], anchor)
self.assertEqual(anchor['message'], 'total={total}')
self.assertEqual(anchor['line'], 10)
self.assertIn(anchor['link_address'], model.instructions)
self.assertEqual(model.symbols[anchor['symbol']], anchor['link_address'])
def test_10_duplicate_macro_tag_preserves_published_package(self):
source = self.fixtures/'logmacro.c'
exe, _ = self.build('logmacro_transaction', [source], '--src-debug')
before = exe.read_bytes()
manifest = (self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes()
duplicate = self.work/'duplicate_log.c'
duplicate.write_text('#include <sdbg.h>\nint main(void) {\n'
'SDBG_LOG(same, "one");\nSDBG_LOG(same, "two");\nreturn 0;\n}\n')
_, result = self.build('logmacro_transaction', [duplicate], '--src-debug', success=False)
self.assertIn('tag должен быть уникальным', result.stdout)
self.assertEqual(exe.read_bytes(), before)
self.assertEqual((self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes(), manifest)
invalid_format = self.work/'invalid_log_format.c'
invalid_format.write_text('#include <sdbg.h>\nint value; int main(void) {'
'SDBG_LOG(value_log,"value={value:04X}");return 0;}')
_, result = self.build('logmacro_transaction', [invalid_format], '--src-debug', success=False)
self.assertIn('только подстановки', result.stdout)
self.assertEqual(exe.read_bytes(), before)
self.assertEqual((self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes(), manifest)
def test_11_banked_macro_uses_typed_location_without_exe_growth(self):
main = self.work/'bank_log_main.c'
bank = self.work/'bank_log_worker.c'
main.write_text('void worker(void) __banked; int main(void) {worker();return 0;}')
bank.write_text('#include <sdbg.h>\nvolatile int bank_value;\n'
'void worker(void) __banked {bank_value=1;'
'SDBG_LOG(bank_hit,"bank_value={bank_value}");bank_value=2;}')
flags = ('--memory', 'big', '--bank', '1='+str(bank))
plain, _ = self.build('bank_log_plain', [main], *flags)
debug, _ = self.build('bank_log_debug', [main], '--src-debug', *flags)
self.assertEqual(plain.read_bytes(), debug.read_bytes())
anchor = self.model('bank_log_debug').logpoints[0]
self.assertTrue(anchor['verified'], anchor)
self.assertEqual((anchor['bank'], anchor['window']), (1, 1))
self.assertEqual(anchor['logical_address'], anchor['link_address'] & 0xffff)
def test_08_archive_debug_descriptions_are_not_imported(self):
directory=self.work/'archive'
directory.mkdir()
sdcc=ROOT/'third_party/sdcc/bin/sdcc'
from sdbg.build import compile_unit
compile_unit(sdcc,ROOT/'third_party/sdcc/bin/sdasz80',ROOT/'libc/string/strlwr.c',
directory/'strlwr.rel',['-mz80','--std-c99','-I',str(ROOT/'libc/include')])
subprocess.run([str(ROOT/'third_party/sdcc/bin/sdar'),'rcs',str(directory/'probe.lib'),
str(directory/'strlwr.rel')],check=True)
source=directory/'app.c'
source.write_text('extern char *strlwr(char *); int main(void) {return *strlwr((char*)0x9000);}')
subprocess.run([str(sdcc),'-mz80','--debug','-c','-o',str(directory/'app.rel'),str(source)],check=True)
subprocess.run([str(sdcc),'-mz80','--debug','--no-std-crt0','-o',str(directory/'app.ihx'),
str(directory/'app.rel'),'-L'+str(directory),'-lprobe'],check=True)
records=(directory/'app.cdb').read_text()
self.assertIn('L:G$strlwr$',records)
self.assertNotIn('F:G$strlwr$',records)
self.assertIn('F:G$strlwr$',(directory/'strlwr.adb').read_text())
if __name__ == '__main__': unittest.main()
+317
View File
@@ -0,0 +1,317 @@
"""Постоянная сессия: локальный RPC и replace source breakpoints."""
from pathlib import Path
import sys
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg.server import SessionController, SessionRpcServer, rpc_call
from sdbg.session import SessionError
class DummyModel:
variables = []
logpoints = []
stale_sources = []
class DummyBridge:
def __init__(self): self.calls = []
def close(self): pass
def request(self, command, **arguments):
self.calls.append((command, arguments))
if command == 'console_print': return {'printed': True}
raise AssertionError(command)
class DummySession:
def __init__(self):
self.bridge = DummyBridge()
self.model = DummyModel()
self.next_id = 1
self.cleared = []
self.activated = 0
def attach(self):
return {'build_id': 'test', 'location': {'status': 'mapped'}}
def break_line(self, filename, line, enabled=True):
if line == 99:
raise SessionError('нет адреса')
identifier = self.next_id
self.next_id += 1
return {'id': identifier, 'backend_ids': [identifier],
'conditions': [], 'locations': [{'line': line}], 'enabled': enabled}
def clear_breakpoint(self, identifier):
self.cleared.append(identifier)
return {'cleared': identifier}
def activate_breakpoints(self):
self.activated += 1
return {'enabled': self.next_id - 1}
def read_variable(self, name, module=None):
if name == 'total': return {'value': 42}
if name == 'flag': return {'value': 0}
raise SessionError('нет переменной')
class MacroSession(DummySession):
def __init__(self):
super().__init__()
self.model = type('MacroModel', (), {
'variables': [], 'stale_sources': [],
'logpoints': [
{'tag': 'authored', 'source': '/src/main.c', 'line': 3,
'verified': True, 'message': 'total={total}',
'condition': None, 'module': None, 'link_address': 0x8100},
{'tag': 'conditional', 'source': '/src/main.c', 'line': 4,
'verified': True, 'message': 'never',
'condition': 'flag', 'module': None, 'link_address': 0x8102},
]})()
def attach(self):
return {'build_id': 'macro', 'location': {'status': 'mapped'},
'capabilities': {'console_print': True}}
def break_anchor(self, location, enabled=True):
identifier = self.next_id
self.next_id += 1
return {'id': identifier, 'backend_ids': [identifier],
'locations': [{'link_address': location['link_address']}],
'conditions': [], 'enabled': enabled}
class StepBridge:
def __init__(self, owner):
self.owner = owner
self.calls = []
def close(self): pass
def request(self, command, **arguments):
self.calls.append(command)
if command in ('step', 'step_over', 'step_out'):
self.owner.index = min(self.owner.index + 1,
len(self.owner.locations) - 1)
return {'accepted': True}
if command == 'snapshot':
return {'state': 'stopped'}
raise AssertionError(command)
def wait_stopped(self, timeout=5):
return {'state': 'stopped'}
class StepSession:
def __init__(self):
self.model = DummyModel()
self.locations = [
{'status': 'mapped', 'link_address': 0x8100,
'sources': [{'file': '/src/main.c', 'line': 3}]},
{'status': 'mapped', 'link_address': 0x8102,
'sources': [{'file': '/src/main.c', 'line': 3}]},
{'status': 'mapped', 'link_address': 0x8104,
'sources': [{'file': '/src/main.c', 'line': 4}]},
]
self.index = 0
self.bridge = StepBridge(self)
def attach(self):
return {'build_id': 'step', 'location': self.locations[0]}
def where(self, snapshot=None):
return self.locations[self.index]
class WaitingBridge:
"""Машинный over ожидает внешний ввод, но pause должен остаться доступен."""
def __init__(self):
self.paused = False
self.calls = []
def close(self): pass
def request(self, command, **arguments):
self.calls.append(command)
if command == 'snapshot':
return {'state': 'stopped' if self.paused else 'running'}
if command == 'pause':
self.paused = True
return {'accepted': True}
if command == 'step_over':
return {'accepted': True}
raise AssertionError(command)
def wait_stopped(self, timeout=5):
return {'state': 'stopped'}
class ServerTests(unittest.TestCase):
@staticmethod
def wait_source_step(controller):
with controller.changed:
if not controller.changed.wait_for(lambda: not controller.running, timeout=2):
raise AssertionError('source step не сообщил остановку')
def test_rpc_roundtrip_and_events(self):
with tempfile.TemporaryDirectory() as directory:
controller = SessionController(DummySession())
try:
server = SessionRpcServer(Path(directory) / 's.sock', controller)
except PermissionError:
controller.close()
self.skipTest('sandbox запрещает bind Unix socket')
thread = threading.Thread(target=server.server.serve_forever, daemon=True)
thread.start()
try:
status = rpc_call(server.path, 'status')
self.assertEqual(status['build_id'], 'test')
events = rpc_call(server.path, 'events', {'after': 0})
self.assertEqual(events['events'][0]['event'], 'stopped')
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
rpc_call(server.path, 'unknown')
finally:
server.server.shutdown()
thread.join()
server.close()
controller.close()
def test_source_breakpoints_replace_and_rollback(self):
session = DummySession()
controller = SessionController(session)
try:
first = controller.call('set_source_breakpoints',
{'file': 'main.c', 'lines': [3, 5]})
self.assertEqual([item['line'] for item in first['breakpoints']], [3, 5])
self.assertEqual(controller.source_breakpoints[str(Path('main.c').resolve())], [1, 2])
controller.call('set_source_breakpoints', {'file': 'main.c', 'lines': [7]})
self.assertEqual(session.cleared, [1, 2])
with self.assertRaisesRegex(SessionError, 'нет адреса'):
controller.call('set_source_breakpoints', {'file': 'main.c', 'lines': [8, 99]})
self.assertIn(4, session.cleared)
self.assertEqual(controller.source_breakpoints[str(Path('main.c').resolve())], [3])
finally:
controller.close()
def test_logpoint_is_bounded_and_does_not_hide_stop(self):
controller = SessionController(DummySession())
try:
result = controller.call('set_source_breakpoints', {
'file': 'main.c',
'breakpoints': [{'line': 3, 'logMessage': 'total={total} {{ok}}'}]})
identifier = result['breakpoints'][0]['id']
controller.breakpoint_info[identifier]['locations'] = [{'link_address': 0x8100}]
self.assertTrue(controller._handle_logpoints({'link_address': 0x8100}))
self.assertEqual(controller.events[-1]['body']['output'], 'total=42 {ok}\n')
controller.breakpoint_info[99] = {
'kind': 'stop', 'message': None,
'locations': [{'link_address': 0x8100}], 'hits': 0}
self.assertFalse(controller._handle_logpoints({'link_address': 0x8100}))
with self.assertRaisesRegex(SessionError, 'только подстановки'):
controller._validate_log_message('{total+1}')
finally:
controller.close()
def test_authored_macro_logs_auto_arm_mirror_and_preserve_user_stop(self):
session = MacroSession()
controller = SessionController(session)
try:
self.assertEqual(session.activated, 1)
self.assertEqual([info['tag'] for info in controller.breakpoint_info.values()],
['authored', 'conditional'])
self.assertTrue(controller._handle_logpoints({'link_address': 0x8100}))
self.assertEqual(controller.events[-1]['body']['output'], 'total=42\n')
self.assertEqual(session.bridge.calls[-1],
('console_print', {'text': 'total=42'}))
before = len(controller.events)
self.assertTrue(controller._handle_logpoints({'link_address': 0x8102}))
self.assertEqual(len(controller.events), before)
controller.breakpoint_info[99] = {
'kind': 'stop', 'message': None,
'locations': [{'link_address': 0x8100}], 'hits': 0}
self.assertFalse(controller._handle_logpoints({'link_address': 0x8100}))
self.assertEqual(controller.events[-1]['body']['output'], 'total=42\n')
controller.call('set_source_breakpoints', {'file': '/src/main.c', 'lines': [5]})
controller.call('set_source_breakpoints', {'file': '/src/main.c', 'lines': []})
self.assertEqual(session.cleared, [3])
self.assertIn(1, controller.breakpoint_info)
finally:
controller.close()
def test_bounded_event_ring_reports_loss(self):
controller = SessionController(DummySession())
try:
for number in range(1025):
controller._emit('output', {'output': str(number)})
result = controller.call('events', {'after': 0, 'timeout': 0})
self.assertEqual(result['lost'], 2) # entry + первый output
self.assertEqual(result['first'], 3)
self.assertEqual(len(result['events']), 1024)
finally:
controller.close()
def test_source_step_skips_same_line_and_uses_over_primitive(self):
session = StepSession()
controller = SessionController(session)
try:
result = controller.call('source_step', {'kind': 'into'})
self.assertEqual(result, {'accepted': True})
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 4)
self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'],
['step', 'step'])
session.index = 0
session.bridge.calls.clear()
result = controller.call('source_step', {'kind': 'over'})
self.assertEqual(result, {'accepted': True})
self.wait_source_step(controller)
self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'],
['step_over', 'step_over'])
session.index = 0
session.bridge.calls.clear()
result = controller.call('source_step', {'kind': 'out'})
self.assertEqual(result, {'accepted': True})
self.wait_source_step(controller)
self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'],
['step_out', 'step_over'])
finally:
controller.close()
def test_source_step_preserves_user_breakpoint_on_same_line(self):
session = StepSession()
controller = SessionController(session)
try:
controller.breakpoint_info[1] = {
'kind': 'stop', 'message': None,
'locations': [{'link_address': 0x8102}], 'hits': 0}
result = controller.call('source_step', {'kind': 'into'})
self.assertEqual(result, {'accepted': True})
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['reason'], 'breakpoint')
finally:
controller.close()
def test_waiting_step_over_releases_session_lock_for_pause(self):
session = StepSession()
session.bridge = WaitingBridge()
controller = SessionController(session)
try:
started = controller.call('source_step', {'kind': 'over'})
self.assertEqual(started, {'accepted': True})
self.assertTrue(controller.running)
stopped = controller.call('pause', {})
self.assertEqual(stopped['sources'][0]['line'], 3)
self.assertFalse(controller.running)
self.assertIsNone(controller.source_step)
self.assertIn('pause', session.bridge.calls)
self.assertEqual(controller.events[-1]['body']['reason'], 'pause')
finally:
controller.close()
if __name__ == '__main__':
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""Source-session: identity, банковские условия и безопасные переменные."""
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg.image import read_ihx
from sdbg.session import DebugSession, SessionError
def record(kind, address, data=b''):
body = bytes((len(data), address >> 8, address & 0xff, kind)) + data
return ':' + (body + bytes((-sum(body) & 0xff,))).hex().upper()
class FakeModel:
def __init__(self, directory):
self.directory = Path(directory)
self.manifest = {'executable': 'test.exe', 'build_id': 'build'}
self.stale_sources = []
self.symbols = {'_bank_pages': 0x8200}
self.instructions = {
0x8100: {'link_address': 0x8100, 'logical_address': 0x8100,
'size': 2, 'bank': None, 'window': 2},
0x1c000: {'link_address': 0x1c000, 'logical_address': 0xc000,
'size': 2, 'bank': 1, 'window': 3},
}
self.functions = [
{'name': 'main', 'link_address': 0x8100, 'logical_address': 0x8100,
'start': 0x8100, 'end': 0x8102, 'bank': None, 'window': 2},
{'name': 'worker', 'link_address': 0x1c000, 'logical_address': 0xc000,
'start': 0x1c000, 'end': 0x1c002, 'bank': 1, 'window': 3},
]
self.variables = [
{'name': 'total', 'module': None, 'size': 2, 'type': 'SI:S',
'signed': True, 'supported': True, 'link_address': 0x8210,
'logical_address': 0x8210, 'bank': None, 'window': 2},
]
def verify_executable(self):
return None
def addr2line(self, address):
return {'status': 'mapped', 'address': address}
def line_locations(self, filename, line):
return {'stale_source': bool(self.stale_sources),
'locations': [self.functions[1]] if line == 7 else []}
class FakeBridge:
def __init__(self, memory, pc=0x8100, pg3=5):
self.memory = memory
self.generation = 1
self.registers = {'PC': pc, 'PG0': 0, 'PG1': 1, 'PG2': 2, 'PG3': pg3}
self.calls = []
self.next_id = 10
def handshake(self):
return {'protocol': 1, 'capabilities': {'bank_guard': True}}
def wait_stopped(self, timeout=5):
return {'state': 'stopped', 'registers': dict(self.registers)}
def request(self, command, **args):
self.calls.append((command, args))
if command == 'memory':
data = bytes(self.memory.get(args['address'] + i, 0)
for i in range(args['length']))
return {'hex': data.hex()}
if command == 'breakpoint':
value = {'id': self.next_id}
self.next_id += 1
return value
if command == 'clear':
return {'cleared': args['id']}
raise AssertionError(command)
class SessionTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
directory = Path(self.temp.name)
ihx = '\n'.join((
record(0, 0x8100, b'\x21\x2a'),
record(4, 0, b'\x00\x01'),
record(0, 0xc000, b'\x3e\x07'),
record(1, 0),
)) + '\n'
(directory / 'test.ihx').write_text(ihx)
self.model = FakeModel(directory)
self.memory = {0x8100: 0x21, 0x8101: 0x2a,
0xc000: 0x3e, 0xc001: 0x07,
0x8200: 0, 0x8201: 7,
0x8210: 0xfe, 0x8211: 0xff}
def tearDown(self):
self.temp.cleanup()
def test_ihx_rejects_checksum(self):
path = Path(self.temp.name) / 'bad.ihx'
path.write_text(':00000001FE\n')
with self.assertRaisesRegex(ValueError, 'сумм'):
read_ihx(path)
def test_attach_and_read_signed_variable(self):
session = DebugSession(self.model, FakeBridge(self.memory))
attached = session.attach()
self.assertEqual(attached['pc'], 0x8100)
self.assertEqual(attached['bank_pages'], {1: 7})
self.assertEqual(session.read_variable('total')['value'], -2)
def test_loaded_image_mismatch_is_rejected(self):
self.memory[0x8100] = 0
with self.assertRaisesRegex(SessionError, 'не соответствует'):
DebugSession(self.model, FakeBridge(self.memory)).attach()
def test_banked_breakpoint_has_page_condition(self):
bridge = FakeBridge(self.memory)
session = DebugSession(self.model, bridge)
session.attach()
result = session.break_line('worker.c', 7)
self.assertEqual(result['backend_ids'], [10])
self.assertIn(('breakpoint', {'address': 0xc000, 'window': 3, 'page': 7}),
bridge.calls)
session.clear_breakpoint(result['id'])
def test_stale_source_and_unmapped_bank_are_rejected(self):
session = DebugSession(self.model, FakeBridge(self.memory))
session.attach()
self.model.stale_sources = ['worker.c']
with self.assertRaisesRegex(SessionError, 'пересоберите'):
session.break_line('worker.c', 7)
bridge = FakeBridge(self.memory, pc=0xc000, pg3=7)
session = DebugSession(self.model, bridge)
attached = session.attach()
self.assertEqual(attached['location']['link_address'], 0x1c000)
if __name__ == '__main__':
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""Контракт файлового канала: владение, атомарный ответ и неизвестный итог."""
import json
import os
from pathlib import Path
import sys
import tempfile
import threading
import time
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg.build import write_json
from sdbg.transport import BridgeError, FileBridge
class TransportTests(unittest.TestCase):
def test_owner_lock(self):
with tempfile.TemporaryDirectory() as directory:
first = FileBridge(directory, 'one')
try:
with self.assertRaisesRegex(BridgeError, 'управляющая'):
FileBridge(directory, 'one')
finally:
first.close()
with self.assertRaisesRegex(BridgeError, 'закрыт'):
first.request('continue')
second = FileBridge(directory, 'one')
second.close()
def test_response_generation_and_session(self):
with tempfile.TemporaryDirectory() as directory:
bridge = FileBridge(directory, 'session', timeout=1)
def backend():
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
requests = list(Path(directory).glob('req_*.json'))
if requests:
request = requests[0]
data = json.loads(request.read_text())
response = request.with_name(request.name.replace('req_', 'resp_'))
write_json(response, {'session': data['session'], 'generation': 7,
'ok': True, 'result': {'state': 'stopped'}})
request.unlink()
return
time.sleep(.001)
thread = threading.Thread(target=backend)
thread.start()
try:
self.assertEqual(bridge.request('snapshot')['state'], 'stopped')
self.assertEqual(bridge.generation, 7)
finally:
thread.join()
bridge.close()
def test_timeout_never_retries_mutation(self):
with tempfile.TemporaryDirectory() as directory:
bridge = FileBridge(directory, 'session', timeout=.025)
try:
with self.assertRaisesRegex(BridgeError, 'Timeout'):
bridge.request('continue')
self.assertTrue(bridge.invalid)
with self.assertRaisesRegex(BridgeError, 'инвалидирован'):
bridge.request('continue')
self.assertFalse(list(Path(directory).glob('req_*.json')))
finally:
bridge.close()
if __name__ == '__main__':
unittest.main()