par_term/debug.rs
1/// Comprehensive debugging infrastructure for par-term
2///
3/// # Dual Logging Systems (ARC-008)
4///
5/// par-term intentionally runs **two parallel logging systems** that both funnel into the
6/// same log file. This coexistence is by design, not accident:
7///
8/// 1. **Custom debug macros** (`crate::debug_info!()`, `crate::debug_log!()`, etc.)
9/// - Controlled by `DEBUG_LEVEL` environment variable (0-4)
10/// - Best for high-frequency rendering/input logging with category tags
11/// - Categories (e.g., `"RENDER"`, `"TAB"`, `"SHADER"`) allow selective filtering
12/// - Zero overhead when `DEBUG_LEVEL=0` (the default)
13///
14/// 2. **Standard `log` crate** (`log::info!()`, `log::warn!()`, etc.)
15/// - Level set by `--log-level` CLI flag, config `log_level`, or `RUST_LOG`
16/// - Used by application lifecycle code (startup, config load, errors)
17/// - Required for third-party crates (wgpu, tokio, etc.) that emit via `log`
18///
19/// ## Why Both Systems Coexist
20///
21/// The custom macros predate widespread `tracing` adoption and were purpose-built for
22/// GPU-loop debugging where `RUST_LOG=debug` would produce millions of lines per second.
23/// The category/level system lets a developer write `DEBUG_LEVEL=3` and see only
24/// rendering events without drowning in tokio internals.
25///
26/// The `log` crate is kept because third-party dependencies (wgpu, tokio, egui) emit
27/// through it exclusively, and because lifecycle events (startup, config) benefit from
28/// the standard `env_logger`/`RUST_LOG` filtering UX.
29///
30/// ## Migration Path (TODO ARC-008)
31///
32/// Long-term, both systems should be unified under `tracing` (the modern Rust async-aware
33/// tracing framework). Migration path:
34/// 1. Replace `crate::debug_info!()` macros with `tracing::trace!(category = "RENDER", ...)`
35/// 2. Replace `log::info!()` calls with `tracing::info!()`
36/// 3. Bridge `log` crate output to tracing with `tracing_log::LogTracer`
37/// 4. Use `tracing_subscriber` with `EnvFilter` for unified level/category control
38/// 5. Keep the file sink; replace the custom `DebugLogger` with a tracing file appender
39///
40/// This is a non-trivial migration touching ~500 call sites. Do not attempt without
41/// a dedicated effort. Until then, the dual system is the accepted state.
42///
43/// Both write to `<temp_dir>/par_term_debug.log` (respects `$TMPDIR` on Unix, `%TEMP%` on Windows);
44/// `cfg(test)` builds divert to a pid-suffixed path so the test binary never truncates the
45/// developer's real log (see [`log_path`]).
46/// The log file is always created so that errors are captured even in GUI-only contexts
47/// (macOS app bundles, Windows GUI apps) where stderr is invisible.
48/// The log file is created with 0600 permissions on Unix (set at creation, not chmod'ed
49/// afterwards) and symlink-checked to prevent attacks. If the path already exists and is
50/// owned by another user, logging is disabled rather than writing where they can read it.
51/// If the file cannot be created at all (missing or unwritable temp dir), a one-time note
52/// on stderr names the path and the error — stderr is the only channel left then.
53///
54/// When `RUST_LOG` is set, `log` crate output is also mirrored to stderr for terminal debugging.
55use parking_lot::Mutex;
56use std::fmt;
57use std::fs::OpenOptions;
58use std::io::Write;
59use std::sync::OnceLock;
60use std::time::{SystemTime, UNIX_EPOCH};
61
62/// Debug level configuration for custom debug macros
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
64pub enum DebugLevel {
65 Off = 0,
66 Error = 1,
67 Info = 2,
68 Debug = 3,
69 Trace = 4,
70}
71
72impl DebugLevel {
73 fn from_env() -> Self {
74 match std::env::var("DEBUG_LEVEL") {
75 Ok(val) => match val.trim().parse::<u8>() {
76 Ok(0) => DebugLevel::Off,
77 Ok(1) => DebugLevel::Error,
78 Ok(2) => DebugLevel::Info,
79 Ok(3) => DebugLevel::Debug,
80 Ok(4) => DebugLevel::Trace,
81 _ => DebugLevel::Off,
82 },
83 Err(_) => DebugLevel::Off,
84 }
85 }
86}
87
88/// Global debug logger that handles both custom debug macros and `log` crate output.
89struct DebugLogger {
90 /// Level for custom debug macros (controlled by DEBUG_LEVEL)
91 level: DebugLevel,
92 /// Log file handle (always opened)
93 file: Option<std::fs::File>,
94 /// Mirror custom debug macros to stderr (true when `RUST_LOG` is set), so
95 /// `make run-debug` (which tees stderr to the log file) captures them live.
96 mirror_stderr: bool,
97}
98
99/// Roll the previous session's log aside to `<log>.1`.
100///
101/// The log is opened with `truncate(true)`, so without this the crash report the
102/// panic hook writes (see `src/session/crash_guard.rs`) is erased by the next
103/// launch — which, after a crash, is usually seconds later.
104///
105/// Rename rather than copy: it is atomic, needs no read of a log that may be
106/// large, and leaves the `.1` file with the 0600 mode it was created under.
107/// Only one generation is kept; a log worth more than that belongs in a tee.
108///
109/// Skipped, leaving any existing `.1` intact, when the log is
110///
111/// - absent, or empty — `make run-debug` pipes through `tee`, which truncates
112/// the path before par-term starts, so rotating there would clobber a real
113/// previous log with nothing;
114/// - not a regular file — a symlink is unlinked by [`open_log_file`] first, and
115/// anything else is not ours to move;
116/// - owned by another user — [`open_log_file`] refuses to write such a path
117/// (SEC-016), and renaming it away would quietly convert that refusal into
118/// "log anyway, into a fresh file".
119fn rotate_log_file(log_path: &std::path::Path) {
120 let Ok(meta) = log_path.symlink_metadata() else {
121 return;
122 };
123 if !meta.is_file() || meta.len() == 0 {
124 return;
125 }
126
127 #[cfg(unix)]
128 {
129 use std::os::unix::fs::MetadataExt;
130 // SAFETY: `getuid` has no preconditions, takes no arguments and always
131 // succeeds.
132 if meta.uid() != unsafe { libc::getuid() } {
133 return;
134 }
135 }
136
137 let mut rolled = log_path.as_os_str().to_owned();
138 rolled.push(".1");
139 let _ = std::fs::rename(log_path, std::path::PathBuf::from(rolled));
140}
141
142/// Report on stderr that the debug log could not be opened.
143///
144/// stderr is the only channel left when the log file cannot exist, and
145/// `open_log_file` runs once per process at logger init, so this fires at most
146/// once — a broken log path must be explained, not silently dropped with every
147/// record.
148fn report_unopenable_log(log_path: &std::path::Path, err: &std::io::Error) {
149 eprintln!(
150 "par-term: cannot open the debug log at {} — {}. Debug file logging is \
151 disabled for this session.",
152 log_path.display(),
153 err
154 );
155}
156
157/// Open (creating or truncating) the debug log with owner-only permissions.
158///
159/// The previous session's log is first rolled aside by [`rotate_log_file`], so
160/// truncation here costs the log of the run before last, not the last one.
161///
162/// Returns `None` — disabling file logging rather than leaking — when the path
163/// cannot be opened safely.
164///
165/// SEC-010: any existing symlink is unlinked first, and on Unix `O_NOFOLLOW` closes
166/// the TOCTOU race between that unlink and the open. On non-Unix platforms the
167/// check-then-open approach is the only available option.
168///
169/// SEC-016: 0600 is requested as the *creation* mode rather than chmod'ed after the
170/// open. A post-open chmod leaves the file world-readable for the length of the
171/// write window and, in the case that actually matters, fails outright — silently —
172/// when the path was pre-created by someone else, because chmod requires ownership.
173/// `temp_dir()` is `/tmp` on Linux: shared and world-writable.
174fn open_log_file(log_path: &std::path::Path) -> Option<std::fs::File> {
175 if log_path
176 .symlink_metadata()
177 .map(|m| m.file_type().is_symlink())
178 .unwrap_or(false)
179 {
180 let _ = std::fs::remove_file(log_path);
181 }
182
183 rotate_log_file(log_path);
184
185 #[cfg(unix)]
186 {
187 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
188 // O_NOFOLLOW (0x20000 on Linux / 0x100 on macOS) causes open() to fail with
189 // ELOOP if the final path component is a symlink, regardless of who created it.
190 match OpenOptions::new()
191 .write(true)
192 .truncate(true)
193 .create(true)
194 .mode(0o600)
195 .custom_flags(libc::O_NOFOLLOW)
196 .open(log_path)
197 {
198 Ok(file) => Some(file).filter(|f| {
199 // The creation mode above only applies to a file *this* process
200 // creates. If the path was pre-created mode 0666 by another user, the
201 // open still succeeds and they keep read access to everything written
202 // here, so refuse to log rather than leak. Checked on the open
203 // descriptor, so there is no TOCTOU window.
204 // SAFETY: `getuid` has no preconditions, takes no arguments and
205 // always succeeds.
206 let our_uid = unsafe { libc::getuid() };
207 match f.metadata() {
208 Ok(meta) if meta.uid() == our_uid => true,
209 Ok(meta) => {
210 eprintln!(
211 "par-term: refusing to write {} — it is owned by uid {}, not \
212 this user. Debug file logging is disabled.",
213 log_path.display(),
214 meta.uid()
215 );
216 false
217 }
218 Err(_) => false,
219 }
220 }),
221 Err(err) => {
222 report_unopenable_log(log_path, &err);
223 None
224 }
225 }
226 }
227
228 #[cfg(not(unix))]
229 {
230 match OpenOptions::new()
231 .write(true)
232 .truncate(true)
233 .create(true)
234 .open(log_path)
235 {
236 Ok(file) => Some(file),
237 Err(err) => {
238 report_unopenable_log(log_path, &err);
239 None
240 }
241 }
242 }
243}
244
245impl DebugLogger {
246 fn new() -> Self {
247 let level = DebugLevel::from_env();
248
249 let file = open_log_file(&log_path());
250
251 let mirror_stderr = std::env::var("RUST_LOG").is_ok();
252 let mut logger = DebugLogger {
253 level,
254 file,
255 mirror_stderr,
256 };
257 logger.write_raw(&format!(
258 "\n{}\npar-term log session started at {} (debug_level={:?}, rust_log={})\n{}\n",
259 "=".repeat(80),
260 get_timestamp(),
261 level,
262 std::env::var("RUST_LOG").unwrap_or_else(|_| "unset".to_string()),
263 "=".repeat(80)
264 ));
265 logger
266 }
267
268 fn write_raw(&mut self, msg: &str) {
269 if let Some(ref mut file) = self.file {
270 let _ = file.write_all(msg.as_bytes());
271 let _ = file.flush();
272 }
273 }
274
275 /// Write a custom debug macro message (respects DEBUG_LEVEL)
276 fn log(&mut self, level: DebugLevel, category: &str, msg: &str) {
277 if level <= self.level {
278 let timestamp = get_timestamp();
279 let level_str = match level {
280 DebugLevel::Error => "ERROR",
281 DebugLevel::Info => "INFO ",
282 DebugLevel::Debug => "DEBUG",
283 DebugLevel::Trace => "TRACE",
284 DebugLevel::Off => return,
285 };
286 self.write_raw(&format!(
287 "[{}] [{}] [{}] {}\n",
288 timestamp, level_str, category, msg
289 ));
290 // Mirror to stderr during debug runs (RUST_LOG set), so `make run-debug`
291 // — which tees stderr to the log file — captures custom-macro output too.
292 if self.mirror_stderr {
293 eprintln!("[{}] {}: {}", level_str.trim_end(), category, msg);
294 }
295 }
296 }
297
298 /// Write a `log` crate record (always writes to file)
299 fn log_record(&mut self, record: &log::Record) {
300 let timestamp = get_timestamp();
301 let level_str = match record.level() {
302 log::Level::Error => "ERROR",
303 log::Level::Warn => "WARN ",
304 log::Level::Info => "INFO ",
305 log::Level::Debug => "DEBUG",
306 log::Level::Trace => "TRACE",
307 };
308 self.write_raw(&format!(
309 "[{}] [{}] [{}] {}\n",
310 timestamp,
311 level_str,
312 record.target(),
313 record.args()
314 ));
315 }
316}
317
318static LOGGER: OnceLock<Mutex<DebugLogger>> = OnceLock::new();
319
320fn get_logger() -> &'static Mutex<DebugLogger> {
321 LOGGER.get_or_init(|| Mutex::new(DebugLogger::new()))
322}
323
324fn get_timestamp() -> String {
325 let now = SystemTime::now()
326 .duration_since(UNIX_EPOCH)
327 .expect("SystemTime::now() is always after UNIX_EPOCH");
328 format!("{}.{:06}", now.as_secs(), now.subsec_micros())
329}
330
331/// Get the path to the debug log file.
332///
333/// The debug macros initialize the file logger through [`get_logger`] even
334/// at `DEBUG_LEVEL=Off` (`is_enabled` locks the logger before the level
335/// check), and the file is opened with `truncate(true)` — so in `cfg(test)`
336/// builds the path is pid-suffixed to keep the test binary from destroying
337/// the developer's real debug log.
338pub fn log_path() -> std::path::PathBuf {
339 if cfg!(test) {
340 std::env::temp_dir().join(format!("par_term_debug_test_{}.log", std::process::id()))
341 } else {
342 std::env::temp_dir().join("par_term_debug.log")
343 }
344}
345
346/// Check if debugging is enabled at given level (for custom debug macros)
347pub fn is_enabled(level: DebugLevel) -> bool {
348 let logger = get_logger().lock();
349 level <= logger.level
350}
351
352/// Log a message at specified level (for custom debug macros)
353pub fn log(level: DebugLevel, category: &str, msg: &str) {
354 let mut logger = get_logger().lock();
355 logger.log(level, category, msg);
356}
357
358/// Log formatted message (for custom debug macros)
359pub fn logf(level: DebugLevel, category: &str, args: fmt::Arguments) {
360 if is_enabled(level) {
361 log(level, category, &format!("{}", args));
362 }
363}
364
365/// Write one line to the debug log without ever blocking. Returns whether it
366/// was written.
367///
368/// Every other entry point takes the logger mutex unconditionally, and
369/// `parking_lot::Mutex` is not reentrant: a panic raised while that lock is held
370/// hangs any hook that then tries to log — the failure mode documented in
371/// `src/session/crash_guard.rs`. This drops the line instead of hanging. It also
372/// never initializes the logger, because re-entering `OnceLock::get_or_init` on
373/// the same thread is its own deadlock.
374///
375/// Two deliberate differences from [`logf`]:
376///
377/// - `DEBUG_LEVEL` does not filter the message. The callers this exists for are
378/// reporting a fault, not tracing, and at the default `DEBUG_LEVEL=0` a
379/// filtered write would produce nothing at all.
380/// - Nothing is mirrored to stderr. A panic hook's stderr output is already
381/// written by the default hook it chains to.
382pub fn try_logf(level: DebugLevel, category: &str, args: fmt::Arguments) -> bool {
383 let level_str = match level {
384 DebugLevel::Error => "ERROR",
385 DebugLevel::Info => "INFO ",
386 DebugLevel::Debug => "DEBUG",
387 DebugLevel::Trace => "TRACE",
388 DebugLevel::Off => return false,
389 };
390 let Some(logger) = LOGGER.get() else {
391 return false;
392 };
393 let Some(mut logger) = logger.try_lock() else {
394 return false;
395 };
396 logger.write_raw(&format!(
397 "[{}] [{}] [{}] {}\n",
398 get_timestamp(),
399 level_str,
400 category,
401 args
402 ));
403 true
404}
405
406// ============================================================================
407// log crate bridge — routes log::info!() etc. to the debug log file
408// ============================================================================
409
410/// Bridge that implements the `log` crate's `Log` trait, routing all log
411/// output to the par-term debug log file. Optionally mirrors to stderr
412/// when `RUST_LOG` is set (for terminal debugging).
413struct LogCrateBridge {
414 /// Install-time level derivation (parsed from RUST_LOG, default: Info). The
415 /// live acceptance level is [`BRIDGE_MAX_LEVEL`]; this field only feeds
416 /// [`init_log_bridge`]'s override decision.
417 max_level: log::LevelFilter,
418 /// Whether to also write to stderr (true when RUST_LOG is explicitly set)
419 mirror_stderr: bool,
420 /// Module-level filters (module_prefix, max_level) for noisy crates
421 module_filters: Vec<(&'static str, log::LevelFilter)>,
422}
423
424impl LogCrateBridge {
425 fn new() -> Self {
426 let rust_log_set = std::env::var("RUST_LOG").is_ok();
427 let max_level = if rust_log_set {
428 // Parse RUST_LOG for the default level (simplified: just use the first token)
429 match std::env::var("RUST_LOG")
430 .unwrap_or_default()
431 .to_lowercase()
432 .as_str()
433 {
434 "trace" => log::LevelFilter::Trace,
435 "debug" => log::LevelFilter::Debug,
436 "info" => log::LevelFilter::Info,
437 "warn" => log::LevelFilter::Warn,
438 "error" => log::LevelFilter::Error,
439 "off" => log::LevelFilter::Off,
440 _ => log::LevelFilter::Info, // default if RUST_LOG has module-specific syntax
441 }
442 } else {
443 // No RUST_LOG: capture info and above to the log file
444 log::LevelFilter::Info
445 };
446
447 LogCrateBridge {
448 max_level,
449 mirror_stderr: rust_log_set,
450 module_filters: vec![
451 ("wgpu_core", log::LevelFilter::Warn),
452 ("wgpu_hal", log::LevelFilter::Warn),
453 ("naga", log::LevelFilter::Warn),
454 ("rodio", log::LevelFilter::Error),
455 ("cpal", log::LevelFilter::Error),
456 ],
457 }
458 }
459
460 fn level_for_module(&self, target: &str) -> log::LevelFilter {
461 for (prefix, filter) in &self.module_filters {
462 if target.starts_with(prefix) {
463 return *filter;
464 }
465 }
466 bridge_max_level()
467 }
468}
469
470impl log::Log for LogCrateBridge {
471 fn enabled(&self, metadata: &log::Metadata) -> bool {
472 metadata.level() <= self.level_for_module(metadata.target())
473 }
474
475 fn log(&self, record: &log::Record) {
476 if !self.enabled(record.metadata()) {
477 return;
478 }
479
480 // Write to the debug log file
481 let mut logger = get_logger().lock();
482 logger.log_record(record);
483 drop(logger);
484
485 // Mirror to stderr when RUST_LOG is set (for terminal debugging)
486 if self.mirror_stderr {
487 eprintln!(
488 "[{}] {}: {}",
489 record.level(),
490 record.target(),
491 record.args()
492 );
493 }
494 }
495
496 fn flush(&self) {}
497}
498
499/// The bridge's live acceptance level. A process-wide atomic rather than a
500/// field because the bridge is boxed into the global logger on install, while
501/// `set_log_level` (settings UI, config application) must still be able to move
502/// the level afterwards. `log::set_max_level` only gates the macros; every
503/// record that passes it is re-checked against this value, so the two must move
504/// together or raised records are silently dropped at the bridge.
505static BRIDGE_MAX_LEVEL: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(3);
506
507fn store_bridge_max_level(level: log::LevelFilter) {
508 let code = match level {
509 log::LevelFilter::Off => 0,
510 log::LevelFilter::Error => 1,
511 log::LevelFilter::Warn => 2,
512 log::LevelFilter::Info => 3,
513 log::LevelFilter::Debug => 4,
514 log::LevelFilter::Trace => 5,
515 };
516 BRIDGE_MAX_LEVEL.store(code, std::sync::atomic::Ordering::Relaxed);
517}
518
519fn bridge_max_level() -> log::LevelFilter {
520 match BRIDGE_MAX_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
521 0 => log::LevelFilter::Off,
522 1 => log::LevelFilter::Error,
523 2 => log::LevelFilter::Warn,
524 3 => log::LevelFilter::Info,
525 4 => log::LevelFilter::Debug,
526 _ => log::LevelFilter::Trace,
527 }
528}
529
530/// Initialize the `log` crate bridge. Call this once from main() instead of env_logger::init().
531/// Routes all `log::info!()` etc. calls to the par-term debug log file.
532/// When `RUST_LOG` is set, also mirrors to stderr for terminal debugging.
533///
534/// `level_override` allows CLI or config to set the level. If `None`, uses
535/// `RUST_LOG` env var (or defaults to `Info`).
536pub fn init_log_bridge(level_override: Option<log::LevelFilter>) {
537 // Force logger initialization (opens the log file)
538 let _ = get_logger();
539
540 let bridge = LogCrateBridge::new();
541 // CLI/config override takes precedence, then RUST_LOG, then default. The
542 // override must reach the bridge's own gate as well: set_max_level below
543 // only gates the macros, and the bridge re-checks every record against
544 // BRIDGE_MAX_LEVEL — left at the RUST_LOG-derived level (Info when RUST_LOG
545 // is unset), it silently drops what the override let through.
546 let max_level = level_override.unwrap_or(bridge.max_level);
547 store_bridge_max_level(max_level);
548
549 // Install as the global logger
550 if log::set_boxed_logger(Box::new(bridge)).is_ok() {
551 log::set_max_level(max_level);
552 }
553}
554
555/// Update the log level at runtime (e.g., from settings UI).
556/// Moves both the macros' global gate and the bridge's per-record gate —
557/// raising only `log::max_level()` would be undone at the bridge's own
558/// `enabled()` check.
559pub fn set_log_level(level: log::LevelFilter) {
560 store_bridge_max_level(level);
561 log::set_max_level(level);
562}
563
564// ============================================================================
565// try_lock failure telemetry
566// ============================================================================
567
568/// Total number of `try_lock()` calls that returned `Err` (lock contended) across all
569/// call sites in the application. Incremented via [`record_try_lock_failure`].
570///
571/// Deliberately a module-level static so it is zero-cost when the counter is never
572/// read — the increment itself is a single `fetch_add(Relaxed)`.
573pub static TRY_LOCK_FAILURE_COUNT: std::sync::atomic::AtomicU64 =
574 std::sync::atomic::AtomicU64::new(0);
575
576/// The value of [`TRY_LOCK_FAILURE_COUNT`] at the time of the last periodic telemetry
577/// log. Used by [`maybe_log_try_lock_telemetry`] to avoid emitting redundant log lines.
578static TRY_LOCK_LAST_REPORTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
579
580/// Record one `try_lock()` failure.
581///
582/// Increments [`TRY_LOCK_FAILURE_COUNT`] and emits a `CONCURRENCY` debug-log entry
583/// (visible at `DEBUG_LEVEL >= 3`).
584///
585/// # Arguments
586/// * `site` - A short, human-readable label identifying the call site
587/// (e.g., `"resize"`, `"theme_change"`, `"focus_event"`).
588#[inline]
589pub fn record_try_lock_failure(site: &str) {
590 let total = TRY_LOCK_FAILURE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
591 logf(
592 DebugLevel::Debug,
593 "CONCURRENCY",
594 format_args!("try_lock() miss at '{}' (lifetime total: {})", site, total),
595 );
596}
597
598/// Return the current lifetime total of `try_lock()` failures.
599///
600/// Intended for periodic telemetry reporting (e.g., once per second in
601/// `about_to_wait` in `crate::app::handler::window_state_impl`).
602#[inline]
603pub fn try_lock_failure_count() -> u64 {
604 TRY_LOCK_FAILURE_COUNT.load(std::sync::atomic::Ordering::Relaxed)
605}
606
607/// Emit a periodic telemetry summary if new `try_lock()` failures have been recorded
608/// since the last call.
609///
610/// This is designed to be called from `about_to_wait` (once per event-loop iteration)
611/// so that lock-contention pressure is surfaced in the debug log without generating
612/// per-failure log spam at higher rates. The log entry is only written when at least
613/// one new failure occurred since the previous call.
614pub fn maybe_log_try_lock_telemetry() {
615 let current = TRY_LOCK_FAILURE_COUNT.load(std::sync::atomic::Ordering::Relaxed);
616 let last = TRY_LOCK_LAST_REPORTED.load(std::sync::atomic::Ordering::Relaxed);
617 if current > last {
618 // Use compare_exchange to ensure only one caller logs when there are
619 // multiple windows. The "loser" simply skips this cycle.
620 if TRY_LOCK_LAST_REPORTED
621 .compare_exchange(
622 last,
623 current,
624 std::sync::atomic::Ordering::Relaxed,
625 std::sync::atomic::Ordering::Relaxed,
626 )
627 .is_ok()
628 {
629 let new_since_last = current - last;
630 logf(
631 DebugLevel::Info,
632 "CONCURRENCY",
633 format_args!(
634 "try_lock telemetry: {} new failure(s) this interval, {} lifetime total",
635 new_since_last, current
636 ),
637 );
638 }
639 }
640}
641
642// ============================================================================
643// Custom debug macros (unchanged, controlled by DEBUG_LEVEL)
644// ============================================================================
645
646#[macro_export]
647macro_rules! debug_error {
648 ($category:expr, $($arg:tt)*) => {
649 $crate::debug::logf($crate::debug::DebugLevel::Error, $category, format_args!($($arg)*))
650 };
651}
652
653#[macro_export]
654macro_rules! debug_info {
655 ($category:expr, $($arg:tt)*) => {
656 $crate::debug::logf($crate::debug::DebugLevel::Info, $category, format_args!($($arg)*))
657 };
658}
659
660#[macro_export]
661macro_rules! debug_log {
662 ($category:expr, $($arg:tt)*) => {
663 $crate::debug::logf($crate::debug::DebugLevel::Debug, $category, format_args!($($arg)*))
664 };
665}
666
667#[macro_export]
668macro_rules! debug_trace {
669 ($category:expr, $($arg:tt)*) => {
670 $crate::debug::logf($crate::debug::DebugLevel::Trace, $category, format_args!($($arg)*))
671 };
672}
673
674// ============================================================================
675// Combined debug-log macros (R-18)
676//
677// Several call sites emit both a `crate::debug_error!` (routed to the custom
678// debug log file, controlled by DEBUG_LEVEL) AND a `log::warn!` / `log::error!`
679// (routed through the standard `log` crate bridge, controlled by RUST_LOG).
680//
681// These two-line patterns are collapsed into a single `debug_and_log!` call:
682//
683// debug_and_log!(WARN, "CATEGORY", "message {}", var);
684// debug_and_log!(ERROR, "CATEGORY", "message {}", var);
685//
686// The macro writes to the custom debug log at the `Error` level **and** emits
687// the same message through `log::warn!` or `log::error!` respectively.
688// ============================================================================
689
690/// Emit a message to both the custom debug log (at `Error` level) and the
691/// standard `log` crate at `log::warn!` level.
692///
693/// # Example
694/// ```ignore
695/// debug_and_log!(WARN, "TMUX", "Failed to attach session '{}': {}", name, e);
696/// ```
697#[macro_export]
698macro_rules! debug_and_log_warn {
699 ($category:expr, $($arg:tt)*) => {{
700 $crate::debug::logf(
701 $crate::debug::DebugLevel::Error,
702 $category,
703 format_args!($($arg)*),
704 );
705 log::warn!($($arg)*);
706 }};
707}
708
709/// Emit a message to both the custom debug log (at `Error` level) and the
710/// standard `log` crate at `log::error!` level.
711///
712/// # Example
713/// ```ignore
714/// debug_and_log_error!("SCRIPT", "Script '{}' failed: {}", name, e);
715/// ```
716#[macro_export]
717macro_rules! debug_and_log_error {
718 ($category:expr, $($arg:tt)*) => {{
719 $crate::debug::logf(
720 $crate::debug::DebugLevel::Error,
721 $category,
722 format_args!($($arg)*),
723 );
724 log::error!($($arg)*);
725 }};
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 /// SEC-016: the log must be owner-only from the moment it is created. A chmod
733 /// after the open leaves a world-readable window and fails silently when the
734 /// path was pre-created by another user.
735 #[cfg(unix)]
736 #[test]
737 fn log_file_is_created_owner_only() {
738 use std::os::unix::fs::PermissionsExt;
739
740 let dir = tempfile::tempdir().expect("temp dir");
741 let path = dir.path().join("par_term_debug.log");
742
743 let file = open_log_file(&path).expect("log file opens");
744 drop(file);
745
746 let mode = std::fs::metadata(&path)
747 .expect("log file created")
748 .permissions()
749 .mode();
750 assert_eq!(
751 mode & 0o777,
752 0o600,
753 "debug log must not be group/world readable"
754 );
755 }
756
757 /// SEC-010 regression: a symlink planted at the log path must not be written
758 /// through, whoever owns it.
759 #[cfg(unix)]
760 #[test]
761 fn log_file_open_does_not_follow_a_planted_symlink() {
762 let dir = tempfile::tempdir().expect("temp dir");
763 let path = dir.path().join("par_term_debug.log");
764 let target = dir.path().join("victim");
765
766 std::fs::write(&target, "original").expect("seed symlink target");
767 std::os::unix::fs::symlink(&target, &path).expect("plant symlink");
768
769 let mut file = open_log_file(&path).expect("log file opens");
770 std::io::Write::write_all(&mut file, b"leaked").expect("write");
771 drop(file);
772
773 assert_eq!(
774 std::fs::read_to_string(&target).expect("read target"),
775 "original",
776 "the log must not be written through the planted symlink"
777 );
778 }
779
780 #[test]
781 fn log_file_is_truncated_on_reopen() {
782 let dir = tempfile::tempdir().expect("temp dir");
783 let path = dir.path().join("par_term_debug.log");
784
785 std::fs::write(&path, "stale session output").expect("seed log");
786
787 let file = open_log_file(&path).expect("log file opens");
788 drop(file);
789
790 assert_eq!(std::fs::read_to_string(&path).expect("read log"), "");
791 }
792
793 /// The debug macros initialize the shared file logger through
794 /// `get_logger()` even at `DEBUG_LEVEL=Off` — `is_enabled` locks the
795 /// logger before the level check — and the file is opened with
796 /// `truncate(true)`. Without the test-build path diversion in
797 /// [`log_path`], any test whose code path contains a `debug_*!` macro
798 /// would destroy the developer's real debug log (observed 2026-09-23: a
799 /// gate run erased a live session's close-path evidence).
800 #[test]
801 fn test_builds_never_touch_the_real_debug_log_path() {
802 assert_ne!(
803 log_path(),
804 std::env::temp_dir().join("par_term_debug.log"),
805 "cfg(test) builds must divert log_path() away from the real debug log"
806 );
807 }
808
809 /// The previous session's log — which after a crash holds the panic report —
810 /// must survive the next launch's truncation.
811 #[test]
812 fn previous_log_is_rolled_aside_on_reopen() {
813 let dir = tempfile::tempdir().expect("temp dir");
814 let path = dir.path().join("par_term_debug.log");
815 let rolled = dir.path().join("par_term_debug.log.1");
816
817 std::fs::write(&path, "PANIC report from the run that crashed").expect("seed log");
818
819 let file = open_log_file(&path).expect("log file opens");
820 drop(file);
821
822 assert_eq!(
823 std::fs::read_to_string(&rolled).expect("previous log rolled aside"),
824 "PANIC report from the run that crashed"
825 );
826 }
827
828 /// `make run-debug` pipes through `tee`, which truncates the log before
829 /// par-term opens it. Rotating an empty log there would replace a real
830 /// previous log with nothing.
831 #[test]
832 fn an_empty_log_is_not_rotated() {
833 let dir = tempfile::tempdir().expect("temp dir");
834 let path = dir.path().join("par_term_debug.log");
835 let rolled = dir.path().join("par_term_debug.log.1");
836
837 std::fs::write(&rolled, "the log worth keeping").expect("seed rolled log");
838 std::fs::write(&path, "").expect("seed empty log");
839
840 let file = open_log_file(&path).expect("log file opens");
841 drop(file);
842
843 assert_eq!(
844 std::fs::read_to_string(&rolled).expect("read rolled log"),
845 "the log worth keeping",
846 "an empty log must not overwrite the previous rotation"
847 );
848 }
849
850 /// A log path that cannot be created must not fail silently — every record
851 /// would be dropped with no explanation anywhere. The failure is reported
852 /// once on stderr (the only channel that survives a broken log file),
853 /// naming the path and the error. Verified in a child process because
854 /// stderr cannot be captured in-process, and with `--nocapture` so the
855 /// child's eprintln is not swallowed by libtest's stream capture.
856 #[test]
857 fn unopenable_log_path_is_reported_on_stderr() {
858 // `--exact` matches the fully-qualified path, so the filter must carry
859 // the module prefix or the child runs zero tests.
860 const TEST_PATH: &str = "debug::tests::unopenable_log_path_is_reported_on_stderr";
861 let bad_path = std::env::temp_dir()
862 .join("par_term_no_such_dir_9f2c")
863 .join("par_term_debug.log");
864
865 if std::env::var("PAR_TERM_CHILD_OPEN_LOG").is_ok() {
866 let _ = open_log_file(&bad_path);
867 return;
868 }
869
870 let out = std::process::Command::new(std::env::current_exe().expect("test binary"))
871 .args(["--exact", "--nocapture", TEST_PATH])
872 .env("PAR_TERM_CHILD_OPEN_LOG", "1")
873 .output()
874 .expect("re-exec the test binary");
875
876 // Positive control: if the child did not run this test (filter
877 // mismatch, bad args), the stderr assertion below would pass or fail
878 // vacuously.
879 let stdout = String::from_utf8_lossy(&out.stdout);
880 assert!(
881 stdout.contains("1 passed"),
882 "child re-exec did not run the test; stdout: {stdout}"
883 );
884
885 let stderr = String::from_utf8_lossy(&out.stderr);
886 assert!(
887 stderr.contains("cannot open the debug log at"),
888 "an unopenable log path must be explained on stderr, got: {stderr}"
889 );
890 }
891
892 /// The panic hook's requirement: report a fault without taking the logger
893 /// mutex, and without being filtered out at the default `DEBUG_LEVEL=0`.
894 #[test]
895 fn try_logf_neither_blocks_nor_initializes_the_logger() {
896 // Only one direction is assertable, and both of the tempting stronger
897 // claims are wrong under `cargo test`'s default parallelism. `LOGGER` is
898 // a `OnceLock`, so a concurrent test can install it between the call and
899 // a later reading. And an installed logger does *not* imply a write:
900 // `try_logf` uses `try_lock` and drops the line when another thread
901 // holds the mutex, which is the whole reason it exists for the panic
902 // hook. So: a write implies a logger, and nothing else.
903 let wrote = try_logf(DebugLevel::Error, "TEST", format_args!("no deadlock"));
904 assert!(
905 !wrote || LOGGER.get().is_some(),
906 "try_logf reported a write with no logger installed"
907 );
908
909 assert!(
910 !try_logf(DebugLevel::Off, "TEST", format_args!("ignored")),
911 "DebugLevel::Off has no line format and must never be written"
912 );
913
914 // Held lock: the panic-hook case. Must return false, not hang.
915 if let Some(logger) = LOGGER.get() {
916 let held = logger.lock();
917 assert!(
918 !try_logf(DebugLevel::Error, "TEST", format_args!("contended")),
919 "try_logf must drop the line while the logger mutex is held"
920 );
921 drop(held);
922 }
923 }
924
925 /// The hazard [`try_logf`] exists to remove, exercised through the caller it
926 /// was wired into: the panic hook's report step must return while the logger
927 /// mutex is held rather than deadlocking on it.
928 ///
929 /// Driven through `crash_guard::report_fields` — the report path with the
930 /// `PanicHookInfo` destructuring lifted off — rather than a real panic. A
931 /// real one is not reproducible from a test: the hook is process-global and
932 /// latched, it binds to whichever thread installs it first, libtest installs
933 /// a hook of its own, and `PanicHookInfo` cannot be constructed.
934 ///
935 /// The contended half only bites once some earlier test has initialized
936 /// `LOGGER`, and nothing here forces that, because initializing it rotates
937 /// and truncates a log file ([`log_path`] pins that to a test-only path in
938 /// `cfg(test)` builds, but it is still an unconditional file open). The
939 /// watchdog turns a reintroduced blocking call — a `log::error!` in that
940 /// path reaches this same mutex through [`LogCrateBridge`] — into a
941 /// failure rather than a run that hangs forever.
942 #[test]
943 fn the_panic_report_never_blocks_on_the_logger() {
944 use crate::session::crash_guard::{PanicReport, SaveOutcome, report_fields};
945
946 fn fields(outcome: Option<SaveOutcome>) -> PanicReport<'static> {
947 PanicReport {
948 thread_name: "test",
949 file: "src/debug.rs",
950 line: 1,
951 column: 1,
952 payload: "provoked",
953 outcome,
954 }
955 }
956
957 // Uncontended: every outcome the hook can report runs to completion.
958 // The hook must stay infallible whichever branch it takes.
959 for outcome in [
960 None,
961 Some(SaveOutcome::Saved),
962 Some(SaveOutcome::NoSnapshot),
963 Some(SaveOutcome::AlreadySaved),
964 Some(SaveOutcome::WriteFailed),
965 ] {
966 report_fields(fields(outcome));
967 }
968
969 let Some(logger) = LOGGER.get() else {
970 return;
971 };
972 let held = logger.lock();
973
974 let (tx, rx) = std::sync::mpsc::channel();
975 std::thread::spawn(move || {
976 report_fields(fields(Some(SaveOutcome::Saved)));
977 let _ = tx.send(());
978 });
979 let finished = rx.recv_timeout(std::time::Duration::from_secs(10)).is_ok();
980 drop(held);
981
982 assert!(
983 finished,
984 "the panic hook's report step blocked on the logger mutex"
985 );
986 }
987
988 /// Card 01a0c0aef20e7442bf26171b8bc1c7b2: a `--log-level debug` override
989 /// raised only `log::set_max_level` while the bridge kept accepting at its
990 /// RUST_LOG-derived level, so DEBUG records passed the macro gate and were
991 /// then dropped by the bridge's own `enabled()` check. Every level source —
992 /// the init-time override and runtime `set_log_level` — must reach
993 /// `BRIDGE_MAX_LEVEL`, not just the global gate.
994 ///
995 /// Exercises the composition `init_log_bridge` uses, minus the two steps a
996 /// test must not take: installing the global logger and initializing the
997 /// file logger (an unconditional truncate-open, pinned to a test-only path
998 /// by [`log_path`] in `cfg(test)` builds).
999 #[test]
1000 fn level_overrides_reach_the_bridge_gate() {
1001 fn meta<'a>(level: log::Level, target: &'a str) -> log::Metadata<'a> {
1002 log::Metadata::builder().level(level).target(target).build()
1003 }
1004
1005 // What init_log_bridge computes for a Debug CLI override: the derived
1006 // RUST_LOG level replaced by the override.
1007 let bridge = LogCrateBridge::new();
1008 store_bridge_max_level(log::LevelFilter::Debug);
1009
1010 assert_eq!(bridge_max_level(), log::LevelFilter::Debug);
1011 assert!(
1012 log::Log::enabled(&bridge, &meta(log::Level::Debug, "par_term::app")),
1013 "--log-level debug must let log::debug! records through the bridge"
1014 );
1015
1016 // The runtime path (settings UI, config application at app start) moves
1017 // the same gate, in both directions.
1018 set_log_level(log::LevelFilter::Warn);
1019 assert!(!log::Log::enabled(
1020 &bridge,
1021 &meta(log::Level::Debug, "par_term::app")
1022 ));
1023 assert!(!log::Log::enabled(
1024 &bridge,
1025 &meta(log::Level::Info, "par_term::app")
1026 ));
1027 assert!(log::Log::enabled(
1028 &bridge,
1029 &meta(log::Level::Warn, "par_term::app")
1030 ));
1031
1032 // Noisy-crate caps are independent of the level and must survive it.
1033 assert!(
1034 !log::Log::enabled(&bridge, &meta(log::Level::Info, "wgpu_core::device")),
1035 "module caps must still clamp wgpu_core below the acceptance level"
1036 );
1037 }
1038}