Skip to main content

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}