Stop HD rumble at a fatal error and a crash too, on a thread of its own the end waits for 300 ms at most.

input_rumble_hd_stop ran only in the orderly ends, so a FATAL or a crash while a controller vibrated on HD rumble
left it buzzing: nothing renews or stops the value after the process is gone (input.c says so). It also took
hd_lock with a blocking lock while it only tried the other two.

dispatch.c rt_rumble_stop_bounded runs input_rumble_hd_stop (weak in dispatch.c, as fs_save_crash_wait is, for
builds without input.c) on a new "rumble stop" thread and waits at most 300 ms for it, never joining it: the stop
goes through SDL, which takes its own joystick lock, and a crash on SDL's thread while it held that lock would
otherwise keep the ending thread there and the process would never end. fatal_end and main.c crash_handler call
it right after rtss_limit_restore. input.c tries hd_lock 50 times 1 ms apart, as it does `lock` and sdl_lock, and
skips the stop when it is not taken.

Not reproduced before the change. tests/fatal_exit_check.py (now linking dispatch.c as its own unit: the stop runs
at a fatal error; a stop that hangs leaves the end at code 70 after 0.3 s; statically both failed ends call the
bounded stop after the RTSS restore), tests/rumble_hd_check.py and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 17:19:38 -05:00
parent 722a5dd1b0
commit 87fd385b7b
6 changed files with 68 additions and 9 deletions

View File

@@ -59,6 +59,25 @@ void rt_fatal_notes(void) {
__attribute__((weak)) void fs_save_crash_wait(void) {} // fs_host.c; builds without it (the unit checks) have no saves
// HD rumble's neutral value before a failed run ends (input.c input_rumble_hd_stop); builds without input.c (the unit
// checks) have no controller.
__attribute__((weak)) void input_rumble_hd_stop(void) {}
// The stop goes through SDL, which takes its own joystick lock: a crash on SDL's thread while it held that lock would
// hold the ending thread there for good, and the process would never end. So it runs on a thread of its own, and the
// ending thread waits for it at most 300 ms and never joins it. A controller left buzzing is the lesser harm.
static volatile int32_t rumble_stopped;
static void rumble_stop_main(void *arg) {
(void)arg;
input_rumble_hd_stop();
__atomic_store_n(&rumble_stopped, 1, __ATOMIC_RELEASE);
}
void rt_rumble_stop_bounded(void) {
if (!plat_thread_spawn(rumble_stop_main, NULL, "rumble stop", 0)) return;
for (int i = 0; i < 30 && !__atomic_load_n(&rumble_stopped, __ATOMIC_ACQUIRE); i++) plat_sleep_ms(10);
}
// One end for a failed run. The platform layer calls the crash handler on every thread that faults, without
// waiting for the others (platform/win32.c crash_veh, crash_filter), and a fatal error can come beside a crash from
// the same bad state (an allocation that failed here, a null pointer read there). The later end cut the first report
@@ -96,6 +115,7 @@ static void fatal_end(int code, const char *fmt, va_list ap) {
// RivaTuner Statistics Server's profile as it was: left with the game's limit in it, the next run would take that
// limit for the player's own (rtss_limit.c; it gives up after a second if the failing thread holds its lock)
rtss_limit_restore();
rt_rumble_stop_bounded(); // a controller on HD rumble: nothing renews or stops its vibration after the end
fflush(stdout);
fflush(stderr);
// Not _Exit: in the C runtime it is ExitProcess, which stops the other threads wherever they are and then runs every

View File

@@ -66,3 +66,5 @@ void rt_fatal_notes(void);
// for the first caller, which reports and ends the process; 0 for that thread again (a failure inside its own report),
// which ends at once with its code; any other thread waits, logging nothing, until the process ends (30 s at most).
int rt_ending_enter(void);
// HD rumble stopped before a failed run ends, on a thread of its own that the caller waits for 300 ms at most.
void rt_rumble_stop_bounded(void);

View File

@@ -717,7 +717,12 @@ void input_rumble_hd_stop(void) {
// The locks are tried for a while and not waited for: a thread of the game may hold one for good by now.
for (int i = 0; i < 50 && !(got = plat_mutex_trylock(&lock)); i++) plat_wait_ns(1000000);
if (!got) return;
plat_mutex_lock(&hd_lock);
got = 0;
for (int i = 0; i < 50 && !(got = plat_mutex_trylock(&hd_lock)); i++) plat_wait_ns(1000000);
if (!got) { // a fatal error or a crash on the thread that writes the reports (dispatch.c rt_rumble_stop_bounded)
plat_mutex_unlock(&lock);
return;
}
hd_ended = 1;
int buzzing = rumble_hd_buzzing(&hd);
rumble_hd_forget(&hd);

View File

@@ -373,6 +373,7 @@ static void crash_handler(const PlatCrash *x) {
kern_free_cores_note();
fs_save_crash_wait(); // a save the game already reported as done finishes first (bounded; fs_host.c)
rtss_limit_restore(); // after the report: RivaTuner Statistics Server's profile as it was
rt_rumble_stop_bounded(); // a controller on HD rumble stops (dispatch.c: 300 ms at most, SDL may be the crash)
fflush(stderr);
prof_sampler_write(); // NX2NAVI_PROFILE: what was sampled until the crash (it gives up after 2 s if the sampler hangs)
plat_exit(71);

View File

@@ -7,14 +7,16 @@
// fatal_exit_check crash_crash two threads fault at once: one whole crash report, exit code 71
// fatal_exit_check fatal_crash a fault on another thread while rt_fatal reports: its FATAL line alone, exit code 70
// fatal_exit_check crash_fatal rt_fatal on another thread while a crash is reported: the whole report, exit code 71
// fatal_exit_check fatal_rumble rt_fatal while the HD rumble stop hangs (SDL's lock held): exit code 70 all the same
//
// The crash handler here is a stand-in for main.c crash_handler with its first line (the static check in
// tests/fatal_exit_check.py holds main.c to it): it writes three report lines 100 ms apart and ends with plat_exit(71).
#include "dispatch.c"
#include <stdio.h>
#include <windows.h>
#include "host.h"
#include "platform/platform.h"
static void say(const char *s) {
DWORD w;
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), s, (DWORD)strlen(s), &w, NULL);
@@ -58,6 +60,13 @@ void prof_sampler_write(void) {}
void rtss_limit_restore(void) {}
void kern_checkpoint(void) {}
// input.c's stop, which dispatch.c calls through a weak symbol: says it ran, or hangs as when SDL's lock is held
static int rumble_hangs;
void input_rumble_hd_stop(void) {
if (rumble_hangs) Sleep(INFINITE);
say("rumble: stopped\n");
}
// ---- the crash report and the threads that fail beside it
static void crash(const PlatCrash *x) {
if (!rt_ending_enter()) plat_exit(71); // main.c crash_handler's first line
@@ -93,6 +102,10 @@ int main(int argc, char **argv) {
tls_var = 1;
plat_crash_hook(crash);
if (!strcmp(mode, "fatal")) rt_fatal("the test's fatal error %d", 1);
if (!strcmp(mode, "fatal_rumble")) {
rumble_hangs = 1;
rt_fatal("the test's fatal error %d", 1);
}
if (!strcmp(mode, "crash_crash")) {
CloseHandle(CreateThread(NULL, 0, fault, (void *)0, 0, NULL));
CloseHandle(CreateThread(NULL, 0, fault, (void *)0, 0, NULL));
@@ -113,6 +126,6 @@ int main(int argc, char **argv) {
fault(0);
return 3;
}
printf("usage: fatal_exit_check fatal|crash_crash|fatal_crash|crash_fatal\n");
printf("usage: fatal_exit_check fatal|crash_crash|fatal_crash|crash_fatal|fatal_rumble\n");
return 2;
}

View File

@@ -4,6 +4,9 @@ ExitProcess makes (a driver's detach that faulted there turned the end into a cr
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.
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).
@@ -15,6 +18,7 @@ import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@@ -73,12 +77,24 @@ def static_checks():
ok = first == 'if (!rt_ending_enter()) plat_exit(71);'
print(f'static: main.c crash_handler starts with the shared guard: {"ok" if ok else "FAIL (" + first + ")"}',
flush=True)
return bad + (not ok)
bad += not ok
# both failed ends stop HD rumble through the bounded stop, after RivaTuner's restore
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_restore('), 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 the RTSS restore: '
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'], ['detach:']),
('fatal', 70, ['FATAL: the test\'s fatal error 1', 'rumble: stopped'], ['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
@@ -104,15 +120,17 @@ def main():
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'),
*map(str, plat), '-o', str(exe), '-lm', *libs], env=env)
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
print(f'{mode}: exit code {p.returncode} (want {code}), {fatal_lines} FATAL line(s)'
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: