Files
NX2navi/tests/exit_tls_check.py
chrg6 b23acff301 Leave a line in the log when a module ends the process with ExitProcess, and write the gathered lines at a focus loss.
Two runs of 2026-11-13 (the 0.10.0 fresh install's nx2navi.1.log, 7,974,927 bytes, and the 0.9.2 test's
nx2navi.3.log) ended seconds after "window lost the focus" with exit code 0, no WER event and no line of their own:
an ExitProcess or exit() called by a module in the process and a kill from outside could not be told apart, and
both lose the up to 250 ms of lines the log thread was gathering. A direct ExitProcess skips perf.c's atexit sync.

(a) ExitProcess stops every other thread and then calls each module's detach, the executable's TLS callbacks
included; TerminateProcess calls none. win32.c exit_note_tls, in the .CRT$XLB list beside _tls_used, writes on
DLL_PROCESS_DETACH what the log queue holds (logq.c logq_peek: the bytes as they stand, no lock) and one line,
"exit: the process is ending through ExitProcess, not one of the game's own ends", with the return addresses that
led there. It runs under the loader lock with the other threads gone, so it uses no C runtime (hex written by hand)
and does not ask the loader: the addresses are named from a module list noted at start and at each focus loss
(plat_exit_note_modules: EnumProcessModules, two tables and a writers' lock the callback never takes), and
RtlCaptureStackBackTrace and WriteFile take no lock of the process. plat_exit, plat_terminate and plat_reexec set
the orderly flag (plat_exit_orderly), and main does once main_run has returned, so a tool mode's exit() leaves no
line; a library's exit() during a game run does, naming ucrtbase and its caller. main.c log_open sets it up for a
game run that writes a log file. Linux has empty stand-ins.

(b) gfx_window_focus_changed calls the new main.c rt_log_focus_lost at a focus loss: logq_nudge makes the log thread
write what it gathers at once (a flag log_main's gathering loop checks) and the module list is noted again.

Not reproduced before the change (the two ends are unexplained). The new tests/exit_tls_check.py links win32.c and
logq.c with a queued line still being gathered: ExitProcess from the program and exit() write the line and the note
naming exit_tls_check.exe; TerminateProcess, plat_exit and a return after plat_exit_orderly write no note.
tests/logq_check.c gains a nudge case, which fails with the flag left unset (252 ms) and passes with it.
thread_end_check, fatal_exit_check, crash_stack_check, exit_order_check, save_share_check and the compile checks of
every runtime file pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:23 -05:00

80 lines
3.2 KiB
Python

"""Build and run tests/exit_tls_check.c: an ExitProcess or exit() that none of the game's own ends asked for leaves the
log queue's lines and one note in the log file, naming the calls that led to it by module and offset
(runtime/platform/win32.c exit_note_tls); TerminateProcess, plat_exit and a return from main after plat_exit_orderly
leave none. Windows only; no game files.
Usage: exit_tls_check.py
"""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
LAYOUT = '''// A stand-in for the generated layout.h (tests/exit_tls_check.py).
#pragma once
#define MOD_MAIN_BASE 0x7000000000ull
#define MOD_MAIN_SIZE 0x4ea0000ull
'''
NOTE = 'exit: the process is ending through ExitProcess'
CASES = [ # mode, the queued line in the file, a note in the file
('exitprocess', True, True),
('exit', True, True),
('terminate', None, False), # the queued line may or may not have been written: the kill came while it gathered
('platexit', True, False),
('return', None, False),
]
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 main():
if os.name != 'nt':
print('exit_tls_check: Windows only')
return 0
work = ROOT / 'build' / 'test' / 'exit_tls'
work.mkdir(parents=True, exist_ok=True)
(work / 'layout.h').write_text(LAYOUT)
exe = work / 'exit_tls_check.exe'
env = dict(os.environ)
if MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
rt = ROOT / 'runtime'
plat = [rt / 'platform' / 'win32.c', rt / 'platform' / 'common.c', rt / 'logq.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' / 'exit_tls_check.c'),
*map(str, plat), '-o', str(exe), '-lm', *libs], env=env)
bad = 0
for mode, queued, note in CASES:
log = work / f'{mode}.log'
log.unlink(missing_ok=True)
rc = subprocess.run([str(exe), mode, str(log)], env=env, timeout=60).returncode
text = log.read_text(errors='replace') if log.exists() else ''
has_q, has_note = 'queued line' in text, NOTE in text
named = bool(re.search(r'exit_tls_check\.exe\+0x[0-9a-f]+', text))
ok = rc == 0 and (queued is None or has_q == queued) and has_note == note and (not note or named)
print(f'{mode}: exit code {rc}, the queued line {"in" if has_q else "not in"} the file, '
f'{"a note" + (" naming this program" if named else " NAMING NOTHING") if has_note else "no note"}'
f'{"" if ok else ": FAIL"}', flush=True)
if not ok:
print(text[-2000:])
bad += not ok
print(f'exit_tls_check: {"FAILED" if bad else "ok"}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())