Files
NX2navi/tests/fatal_exit_check.py
chrg6 04f4d44747 Keep RivaTuner Statistics Server's profile as it was in rtss-profile.txt, so a start after a kill or crash puts it back, and leave it to that start at a crash.
An exit that skipped rtss_limit_restore (Task Manager, a hang, lost_wait_exit, a kill during the pipeline save) left
the game's limit in Profiles\<exe>.cfg, and the next run's first apply took it for the player's own: that run then
put the stale limit back at its close, and a profile the game had made stayed for good (lane A's probe: kill1, then
kill2 'held a limit of 60/1'). The crash handler and the fatal error's end also called into RTSSHooks64.dll from a
process that had failed (P-13).

rtss_limit.c now keeps one line per profile in rtss-profile.txt next to the executable (rtss_decide.h RtssSaved:
whether the profile existed, its old limit and denominator, the value the run wrote), written atomically before the
run's first change of the profile, updated with each value written, and removed once the profile is back (put_back;
also when the first write did not stick). On the first poll with the hooks library a start that finds a line of its
profile puts the profile back first (restore_left, rtss_restore_plan): the old limit, or no profile, unless the player
changed or removed it since, which the written value tells. main.c crash_handler and dispatch.c fatal_end call the
new rtss_limit_crash, which closes the run and leaves the profile to that start with no call into RTSS's library; only
when the file could not be written does it put the profile back as before, and never from the worker's own thread. A
file that cannot be written leaves the run as it was, with one line in the log. Profiles frozen by earlier versions
cannot be told from the player's own.

Not reproduced before the change. rtss_limit_check (T12: the line's format and parse, the file's rewrite, each plan;
the static check that both crash paths go through rtss_limit_crash), rtss_worker_check (state_record, state_player,
state_unwritable, crash_recorded, and kill_restart with its player and edited variants, each a killed run and the next
start in processes of their own), fatal_exit_check and exit_order_check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:25 -05:00

173 lines
9.6 KiB
Python

"""Build and run tests/fatal_exit_check.c: the end of a fatal error (runtime/dispatch.c fatal_end) with the platform
layer linked. The process ends with its own code and its FATAL line, and without the libraries' detach calls that
ExitProcess makes (a driver's detach that faulted there turned the end into a crash report with another code). Also
checks statically that no runtime file outside the platform layer ends the process with _Exit, and that the fatal
error's end and the lost device's (runtime/vk_device.c lost_wait_exit) end with plat_terminate. No game files, no GPU.
A fatal error stops HD rumble on a thread of its own and waits for it 300 ms at most (runtime/dispatch.c
rt_rumble_stop_bounded), so a stop that hangs on SDL's lock does not hold the end. RivaTuner's profile is left to the
next start (runtime/rtss_limit.c rtss_limit_crash), which on RTSS's own thread ("rtss"), whose lock that thread may
hold, makes no call into RTSS's library and takes no lock.
Two failures at once end once: two threads that fault, a fault while a fatal error is reported and a fatal error
while a crash is: the first report is written whole, the process ends with its code, and the second adds no line
(runtime/dispatch.c rt_ending_enter, which runtime/main.c crash_handler calls first).
Usage: fatal_exit_check.py
"""
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RT = ROOT / 'runtime'
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
LAYOUT = '''// A stand-in for the generated layout.h (tests/fatal_exit_check.py).
#pragma once
#define MOD_MAIN_BASE 0x7000000000ull
#define MOD_MAIN_SIZE 0x4ea0000ull
'''
def compiler():
if os.environ.get('CC'):
return os.environ['CC']
if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists():
return str(MSYS_BIN / 'clang.exe')
return shutil.which('clang') or 'clang'
def function(source, signature):
start = source.index(signature)
brace = source.index('{', start)
depth, end = 1, brace + 1
while depth:
depth += (source[end] == '{') - (source[end] == '}')
end += 1
return source[start:end]
def static_checks():
"""_Exit is ExitProcess in the C runtime: the platform layer alone ends the process (plat_exit, plat_terminate)."""
bad = 0
for path in sorted(RT.rglob('*')):
rel = path.relative_to(RT).as_posix()
if path.suffix not in ('.c', '.cpp', '.h', '.inc') or rel.startswith(('platform/', 'third_party/')):
continue
code = re.sub(r'//[^\n]*', '', path.read_text(encoding='utf-8', errors='replace'))
for m in re.finditer(r'\b_Exit\s*\(', code):
print(f'FAIL: runtime/{rel}:{code.count(chr(10), 0, m.start()) + 1} ends the process with _Exit '
'(ExitProcess: every library\'s detach runs); use plat_terminate or plat_exit')
bad += 1
for file, sig in (('dispatch.c', 'static void fatal_end(int code, const char *fmt, va_list ap) {'),
('vk_device.c', 'static void lost_wait_exit(void *arg)')):
body = re.sub(r'//[^\n]*', '', function((RT / file).read_text(encoding='utf-8'), sig))
if 'plat_terminate(' not in body:
print(f'FAIL: runtime/{file} {sig.split("(")[0].split()[-1]} does not end with plat_terminate')
bad += 1
print(f'static: no _Exit outside the platform layer, fatal_end and lost_wait_exit end with plat_terminate: '
f'{"FAILED" if bad else "ok"}', flush=True)
# the crash handler's guard is the one the stand-in in tests/fatal_exit_check.c runs
main_c = (RT / 'main.c').read_text(encoding='utf-8')
body = re.sub(r'//[^\n]*', '', function(main_c, 'static void crash_handler(const PlatCrash *x)'))
first = body[body.index('{') + 1:].strip().split('\n')[0].strip()
ok = first == 'if (!rt_ending_enter(71)) plat_exit(71);'
print(f'static: main.c crash_handler starts with the shared guard: {"ok" if ok else "FAIL (" + first + ")"}',
flush=True)
bad += not ok
# the fatal error's summary leaves out what the failure may have left half made, and only tries the map's lock
flush = re.sub(r'//[^\n]*', '', function((RT / 'perf.c').read_text(encoding='utf-8'), 'void perf_summary_flush_try(void)'))
ok = ('stutter_report_as(' in flush and 'stutter_report(' not in flush and 'gfx_exit_log' not in flush
and 'seg_close' not in flush)
print(f'static: perf.c perf_summary_flush_try tries the stutter map\'s lock and leaves the back end out: '
f'{"ok" if ok else "FAIL"}', flush=True)
bad += not ok
# both failed ends stop HD rumble through the bounded stop, after RivaTuner's profile is left to the next start
fatal = re.sub(r'//[^\n]*', '', function((RT / 'dispatch.c').read_text(encoding='utf-8'),
'static void fatal_end(int code, const char *fmt, va_list ap) {'))
for name, code in (('dispatch.c fatal_end', fatal), ('main.c crash_handler', body)):
r, s = code.find('rtss_limit_crash('), code.find('rt_rumble_stop_bounded(')
ok = 0 <= r < s and 'input_rumble_hd_stop(' not in code
print(f'static: {name} stops HD rumble through rt_rumble_stop_bounded after rtss_limit_crash: '
f'{"ok" if ok else "FAIL"}', flush=True)
bad += not ok
# a first report that hangs: the thread that waits for it ends the process after 30 s with that report's code, and
# fatal_end hands its own code to the guard (a hung FATAL must not read as a crash to the launcher)
dispatch = re.sub(r'//[^\n]*', '', (RT / 'dispatch.c').read_text(encoding='utf-8'))
enter = function(dispatch, 'int rt_ending_enter(int code)')
ok = ('__atomic_store_n(&ending_code, code' in enter
and 'plat_terminate(__atomic_load_n(&ending_code' in enter and 'rt_ending_enter(code)' in fatal)
print(f'static: dispatch.c rt_ending_enter ends a hung first report with that report\'s code: '
f'{"ok" if ok else "FAIL"}', flush=True)
bad += not ok
# the fatal error's frame-time summary comes after RTSS's end and the rumble stop, as in the orderly end
s, p = fatal.find('rt_rumble_stop_bounded('), fatal.find('perf_summary_flush_try(')
ok = 0 <= s < p
print(f'static: dispatch.c fatal_end writes the frame-time summary after RTSS\'s end and the rumble stop: '
f'{"ok" if ok else "FAIL"}', flush=True)
bad += not ok
return bad
CASES = [
# mode, exit code, lines that must be there, lines that must not
('fatal', 70, ['FATAL: the test\'s fatal error 1', 'frametime: at a fatal error', 'rumble: stopped',
'rtss: left to the next start'], ['detach:']),
# rt_fatal on RTSS's own thread: rtss_limit_crash runs from there too, and takes no lock there
('fatal_rtss', 70, ['FATAL: the test\'s fatal error 3', 'rumble: stopped', 'rtss: left to the next start'],
['detach:']),
# the rumble stop hangs (SDL's lock held by a thread that crashed): the end goes on after 300 ms
('fatal_rumble', 70, ['FATAL: the test\'s fatal error 1'], ['rumble: stopped', 'detach:']),
# two faults at once: the first report whole, the second thread waits (dispatch.c rt_ending_enter)
('crash_crash', 71, ['report line 1', 'report line 2', 'report line 3'], ['detach:', 'FATAL:']),
# a fault while rt_fatal reports: its FATAL line and notes whole, no crash report, rt_fatal's code
('fatal_crash', 70, ['FATAL: the test\'s fatal error 1', 'fatal note: end'], ['report line', 'detach:']),
# rt_fatal on another thread while a crash is reported: the report whole, the crash report's code
('crash_fatal', 71, ['report line 1', 'report line 2', 'report line 3'], ['FATAL:', 'detach:']),
]
def main():
bad = static_checks()
if os.name != 'nt':
print('fatal_exit_check: the program part runs on Windows only')
return 1 if bad else 0
work = ROOT / 'build' / 'test' / 'fatal_exit'
work.mkdir(parents=True, exist_ok=True)
(work / 'layout.h').write_text(LAYOUT)
exe = work / 'fatal_exit_check.exe'
env = dict(os.environ)
if MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
plat = [RT / 'platform' / 'win32.c', RT / 'platform' / 'common.c']
libs = ['-lbcrypt', '-lws2_32', '-lpsapi', '-lwinmm', '-ldbghelp', '-lshell32', '-lole32', '-luser32', '-ladvapi32']
subprocess.check_call([compiler(), '-O1', '-g', '-Wall', '-Wno-unused-function', '-fno-strict-aliasing',
'-march=x86-64-v3', '-I', str(RT), '-I', str(work), str(ROOT / 'tests' / 'fatal_exit_check.c'),
str(RT / 'dispatch.c'), *map(str, plat), '-o', str(exe), '-lm', *libs], env=env)
for mode, code, want, unwanted in CASES:
t0 = time.monotonic()
p = subprocess.run([str(exe), mode], env=env, capture_output=True, text=True, errors='replace', timeout=120)
took = time.monotonic() - t0
out = p.stdout + p.stderr
missing = [w for w in want if w not in out]
extra = [u for u in unwanted if u in out]
fatal_lines = out.count('FATAL:')
ok = p.returncode == code and not missing and not extra and took < 20
print(f'{mode}: exit code {p.returncode} (want {code}) after {took:.1f} s, {fatal_lines} FATAL line(s)'
+ (f', missing {missing}' if missing else '') + (f', unexpected {extra}' if extra else '')
+ ('' if ok else ': FAIL'), flush=True)
if not ok:
print(out[-3000:])
bad += not ok
print(f'fatal_exit_check: {"FAILED" if bad else "ok"}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())