Sprinter: добавить отладку C-исходников и интеграцию VS Code
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/* Общий inline выявляет коллизии CDB между единицами трансляции. */
|
||||
static inline unsigned char transform(unsigned char value)
|
||||
{
|
||||
return value ^ 0x5a;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#include "common.h"
|
||||
unsigned char helper(unsigned char value)
|
||||
{
|
||||
return transform(value);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
static int counter;
|
||||
int left(void) { counter = 1; return counter; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
static int counter;
|
||||
int right(void) { counter = 2; return counter; }
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user