stall: the Linux client watches its own main thread
It froze on him this morning while learning recipes at a trainer, and he had to kill it. Both logs end on an ordinary line; the fps journal's last sample is a second earlier with normal numbers; `journalctl -k` holds nothing but firewall noise between 07:30 and 08:20, so no GPU reset and no OOM kill; and the two crash reports in Diagnostics are from the 11th and the 15th by their own embedded server-time lines. A frozen frame writes no line of its own, and a force-kill writes no crash report, so the session left no evidence of where it stopped. There was a watchdog for exactly this, with a heartbeat, a threshold set, an `_exit` backstop and two injectors to test it — registered `#[cfg(all(feature = "dev", target_os = "macos"))]`, because its diagnosis is `/usr/bin/sample`. On Linux nothing was watching at all. So the sampler grows a Linux arm instead of a second watchdog beside it. `/proc` already knows: each thread's scheduler state, the kernel function it sleeps in, and its CPU time in clock ticks. That is less than a backtrace, but it settles the first question — climbing ticks is a loop, `S` or `D` with flat ticks is a wait and the wchan says what for — and it is free, where a real backtrace of our own process needs ptrace, which yama's `ptrace_scope=1` refuses between siblings and which this client has no business asking for on a player's machine. The report says so, and names the pid for `sudo eu-stack -p <pid>` by hand. Verified with the injector that was already there, `WOW_STALL_INJECT=20:14000`: stall: no frame finished in 10072 ms — main thread tid 2793492 state S wchan hrtimer_nanosleep cpu 667 ticks benilla; 39 thread(s) in all `S` on `hrtimer_nanosleep` is the injected sleep, read correctly as a wait. The full report lists all 39 threads by CPU. The in-frame verdict goes to the log as well as the file, because the log is what gets read after a freeze and a file nobody knows about is not evidence. Per-platform where the platforms differ: Linux waits ten seconds rather than 600 ms, since this client has honest frames of several (a world entry's UI load, a `/reload`), and takes more reports, since reading `/proc` suspends nothing — where a `sample` suspends the audio thread, which is itself a crackle, and still declines for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
//! - [`hud`]: the cost pill,
|
||||
//! - [`trace`] / [`journal`]: the CSV instruments (`WOW_STREAM_TRACE`, `WOW_FPS_JOURNAL`); the
|
||||
//! journal also answers the `fpsJournal` CVar, in every build,
|
||||
//! - [`stall`]: the stuck-main-thread self-sampler (macOS),
|
||||
//! - [`stall`]: the stuck-main-thread self-sampler (`sample` on macOS, `/proc` on Linux),
|
||||
//! - [`census`]: the env-gated premise counters.
|
||||
//!
|
||||
//! `clock` and `journal` compile in the player build; everything else is
|
||||
@@ -38,7 +38,9 @@ mod main_split;
|
||||
mod phases;
|
||||
#[cfg(feature = "dev")]
|
||||
mod render_stage;
|
||||
#[cfg(all(feature = "dev", target_os = "macos"))]
|
||||
// Every platform: the Linux arm reads `/proc` where macOS runs `sample`, and the teardown
|
||||
// backstop is platform-independent.
|
||||
#[cfg(feature = "dev")]
|
||||
mod stall;
|
||||
#[cfg(feature = "dev")]
|
||||
mod stats;
|
||||
@@ -142,9 +144,10 @@ impl Plugin for PerfPlugin {
|
||||
app.insert_resource(c);
|
||||
app.add_systems(Last, census::res_census::res_census);
|
||||
}
|
||||
// `WOW_CRASH_INJECT=<at>`: armed here, on every platform, not in the macOS-only sampler.
|
||||
// `WOW_CRASH_INJECT=<at>`: armed here, beside the stall injectors rather than inside the
|
||||
// sampler, so a crash can be injected with the watchdog off.
|
||||
crash_inject::arm(app);
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(feature = "dev")]
|
||||
stall::plugin(app);
|
||||
phases::plugin(app);
|
||||
gpu::plugin(app);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
//! The stuck-main-thread self-sampler (macOS only; `WOW_STALL_SAMPLE=0` disables it): a watchdog
|
||||
//! thread sees the main-thread heartbeat go stale and runs `/usr/bin/sample` on our own PID, so a
|
||||
//! stall or teardown hang diagnoses itself on any run. Samples land in
|
||||
//! `benilla-config/Diagnostics/` and the path prints on stderr, since the tracing subscriber may
|
||||
//! be gone during teardown.
|
||||
//! The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` disables it): a watchdog thread sees
|
||||
//! the main-thread heartbeat go stale and asks the OS what the process is doing, so a stall or
|
||||
//! teardown hang diagnoses itself on any run. Reports land in `benilla-config/Diagnostics/` and
|
||||
//! the path prints on stderr, since the tracing subscriber may be gone during teardown.
|
||||
//!
|
||||
//! **macOS** runs `/usr/bin/sample`, a real backtrace of every thread. **Linux** reads `/proc`
|
||||
//! instead: each thread's scheduler state, the kernel function it sleeps in (`wchan`) and its CPU
|
||||
//! time. That is less than a backtrace but it settles the first question — **climbing CPU time is
|
||||
//! a loop, flat time in `S` or `D` is a wait, and the wchan says what for** — and it costs
|
||||
//! nothing, where a real Linux backtrace of our own process needs ptrace, which `yama`'s
|
||||
//! `ptrace_scope=1` refuses between siblings and which this client is not going to ask for on a
|
||||
//! player's machine. The player can still run `sudo eu-stack -p <pid>` by hand on a wedged client
|
||||
//! before killing it; the report names the pid for exactly that.
|
||||
//!
|
||||
//! The client froze on the player's machine on 2026-10-19 with nothing in the log and nothing in
|
||||
//! the kernel ring, which is the hole this fills: the watchdog was registered on macOS only, so
|
||||
//! on Linux no heartbeat was ever watched.
|
||||
//!
|
||||
//! In-frame sampling is skipped while an audio device is open: `sample` suspends every thread,
|
||||
//! the audio IO thread included, and each suspension is a crackle. `WOW_STALL_SAMPLE=force`
|
||||
@@ -34,8 +46,17 @@ static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
/// The status `AppExit` asked for, so the backstop never turns a failed run into a passing one.
|
||||
static EXIT_CODE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// In-frame staleness that means a real stall (ms), above the ~250 ms loading-screen hitches.
|
||||
const STALL_MS: u64 = 600;
|
||||
/// In-frame staleness that means a real stall (ms).
|
||||
///
|
||||
/// macOS samples at 600 ms, above the ~250 ms loading-screen hitches, because a `sample` is cheap
|
||||
/// to take and the suspension is brief. The Linux report is cheaper still but its budget is the
|
||||
/// player's attention, and this client has honest frames of several seconds — a world entry's UI
|
||||
/// load and a `/reload` both — so it waits until no frame has finished for ten.
|
||||
const STALL_MS: u64 = if cfg!(target_os = "macos") {
|
||||
600
|
||||
} else {
|
||||
10_000
|
||||
};
|
||||
/// In-frame sampling stays disarmed this long after launch, since startup legitimately stalls
|
||||
/// past [`STALL_MS`]; teardown is unaffected.
|
||||
const STARTUP_GRACE_MS: u64 = 15_000;
|
||||
@@ -44,8 +65,10 @@ const STARTUP_GRACE_MS: u64 = 15_000;
|
||||
const EXIT_STALL_MS: u64 = 2_500;
|
||||
/// Teardown backstop: still alive this long after `AppExit`, the process `_exit`s.
|
||||
const EXIT_KILL_MS: u64 = 8_000;
|
||||
/// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart.
|
||||
const SAMPLE_CAP: u32 = 5;
|
||||
/// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart. A Linux
|
||||
/// report suspends nothing, and a freeze is read from the *deltas* between consecutive reports, so
|
||||
/// it takes more of them.
|
||||
const SAMPLE_CAP: u32 = if cfg!(target_os = "macos") { 5 } else { 12 };
|
||||
const SAMPLE_GAP_MS: u64 = 20_000;
|
||||
|
||||
fn since_start_ms() -> u64 {
|
||||
@@ -148,8 +171,13 @@ fn watchdog(dir: Option<std::path::PathBuf>) {
|
||||
&& (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
|
||||
{
|
||||
// Not while an audio device is open: `sample` suspends the audio IO thread and the
|
||||
// sound server times the client out, a crackle. Teardown samples regardless.
|
||||
if !exiting && crate::sound::output::device_open() && !force {
|
||||
// sound server times the client out, a crackle. Teardown samples regardless, and
|
||||
// reading `/proc` suspends nothing, so only macOS declines.
|
||||
if cfg!(target_os = "macos")
|
||||
&& !exiting
|
||||
&& crate::sound::output::device_open()
|
||||
&& !force
|
||||
{
|
||||
if !declined {
|
||||
eprintln!(
|
||||
"stall-sample: main thread stale {age} ms — NOT sampled: an audio \
|
||||
@@ -177,18 +205,118 @@ fn watchdog(dir: Option<std::path::PathBuf>) {
|
||||
if exiting { " (teardown)" } else { "" },
|
||||
file.display()
|
||||
);
|
||||
// 1 s in-frame so the stall dominates the capture; 2 s at teardown, where the wedge
|
||||
// holds throughout.
|
||||
let _ = std::process::Command::new("/usr/bin/sample")
|
||||
.arg(&pid)
|
||||
.arg(if exiting { "2" } else { "1" })
|
||||
.arg("-file")
|
||||
.arg(&file)
|
||||
.status();
|
||||
sample_into(&pid, exiting, &file, age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the OS what the process is doing and write it to `file`.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn sample_into(pid: &str, exiting: bool, file: &std::path::Path, _age: u64) {
|
||||
// 1 s in-frame so the stall dominates the capture; 2 s at teardown, where the wedge holds
|
||||
// throughout.
|
||||
let _ = std::process::Command::new("/usr/bin/sample")
|
||||
.arg(pid)
|
||||
.arg(if exiting { "2" } else { "1" })
|
||||
.arg("-file")
|
||||
.arg(file)
|
||||
.status();
|
||||
}
|
||||
|
||||
/// Every thread's state, wchan and CPU time, from `/proc`. Written to `file` in full, and the main
|
||||
/// thread's line also goes to the log, because the log is what gets read after a freeze and a file
|
||||
/// nobody knows about is not evidence.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sample_into(pid: &str, exiting: bool, file: &std::path::Path, age: u64) {
|
||||
let threads = read_threads();
|
||||
let main = threads.first().cloned().unwrap_or_default();
|
||||
let body = format!(
|
||||
"benilla stall report\npid: {pid}\nstale: {age} ms with no finished frame{}\n\n Each line is one thread: state, the kernel function it sleeps in, and its CPU time in\n clock ticks. R or climbing ticks is a loop; S or D with flat ticks is a wait, and the\n wchan names what for. A backtrace needs ptrace, which yama refuses here — run\n `sudo eu-stack -p {pid}` by hand if the client is still wedged.\n\n{}\n",
|
||||
if exiting { " (during teardown)" } else { "" },
|
||||
threads
|
||||
.iter()
|
||||
.map(ThreadState::line)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
let _ = std::fs::write(file, body);
|
||||
warn!(
|
||||
"stall: no frame finished in {age} ms — main thread {}; {} thread(s) in all, full report \
|
||||
in {}",
|
||||
main.line(),
|
||||
threads.len(),
|
||||
file.display(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One thread as `/proc` describes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Default)]
|
||||
struct ThreadState {
|
||||
tid: u32,
|
||||
name: String,
|
||||
state: String,
|
||||
wchan: String,
|
||||
ticks: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ThreadState {
|
||||
fn line(&self) -> String {
|
||||
format!(
|
||||
"tid {:<8} state {:<2} wchan {:<24} cpu {} ticks {}",
|
||||
self.tid, self.state, self.wchan, self.ticks, self.name
|
||||
)
|
||||
}
|
||||
|
||||
fn read(tid: u32) -> Option<Self> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/self/task/{tid}/stat")).ok()?;
|
||||
// The comm field is parenthesised and may hold spaces and parentheses of its own, so the
|
||||
// fields start after its LAST `)`, exactly as `proc(5)` says to parse it.
|
||||
let (head, rest) = stat.rsplit_once(')')?;
|
||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||
let ticks = |i: usize| -> u64 { fields.get(i).and_then(|v| v.parse().ok()).unwrap_or(0) };
|
||||
Some(Self {
|
||||
tid,
|
||||
name: head
|
||||
.split_once('(')
|
||||
.map_or_else(String::new, |(_, c)| c.to_string()),
|
||||
state: (*fields.first().unwrap_or(&"?")).to_string(),
|
||||
// Often `0` for a thread that is not sleeping, and not always readable.
|
||||
wchan: std::fs::read_to_string(format!("/proc/self/task/{tid}/wchan"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty() && s != "0")
|
||||
.unwrap_or_else(|| String::from("-")),
|
||||
// `utime` and `stime`, the 12th and 13th fields after the state.
|
||||
ticks: ticks(11) + ticks(12),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Every thread of this process, the main thread first: its tid is the pid, since winit's event
|
||||
/// loop is the process's first thread.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_threads() -> Vec<ThreadState> {
|
||||
let main = std::process::id();
|
||||
let mut out: Vec<ThreadState> = ThreadState::read(main).into_iter().collect();
|
||||
let Ok(dir) = std::fs::read_dir("/proc/self/task") else {
|
||||
return out;
|
||||
};
|
||||
let mut rest: Vec<ThreadState> = dir
|
||||
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u32>().ok())
|
||||
.filter(|&tid| tid != main)
|
||||
.filter_map(ThreadState::read)
|
||||
.collect();
|
||||
rest.sort_by_key(|t| std::cmp::Reverse(t.ticks));
|
||||
out.append(&mut rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Neither `sample` nor `/proc`: the watchdog still runs, and its teardown backstop with it.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn sample_into(_pid: &str, _exiting: bool, _file: &std::path::Path, _age: u64) {}
|
||||
|
||||
/// `WOW_STALL_INJECT=<at_secs>:<ms>`: one deliberate main-thread sleep, mid-run.
|
||||
#[derive(Resource)]
|
||||
struct StallInject {
|
||||
|
||||
Reference in New Issue
Block a user