Skip to main content

zsh/extensions/
log.rs

1//! zshrs logging & profiling framework.
2//!
3//! **zshrs-original infrastructure — no C source counterpart.** C
4//! zsh emits all diagnostics through `zwarn()` / `zwarnnam()` /
5//! `zerr()` / `zerrnam()` in Src/utils.c, which print directly to
6//! stderr. This module replaces that pattern with a structured
7//! `tracing`-based subscriber that writes to `~/.zshrs/zshrs.log`.
8//! Per the project's "no startup chatter" rule, all init progress
9//! goes here instead of stderr.
10//!
11//! **Logging** (always on):
12//!   - File: `$HOME/.zshrs/{binary}.log` — five separate files so the
13//!     processes don't interleave their tracing output:
14//!       - `zshrs.log`           (the shell — interactive / scripted)
15//!       - `zshrs-lsp.log`       (`zshrs --lsp` — IDE language server)
16//!       - `zshrs-dap.log`       (`zshrs --dap` — IDE debug adapter)
17//!       - `zshrs-daemon.log`    (daemon, set up via daemon/log.rs)
18//!       - `zshrs-recorder.log`  (single-shot recorder runs)
19//!     The IDE plugin side has its own `zshrs-plugin.log` written by
20//!     `editors/intellij/.../ZshrsDebugLog.kt`.
21//!   - Level: ZSHRS_LOG env var (default: info)
22//!   - Structured key=value fields, ISO timestamps, thread names, module paths
23//!
24//! **Profiling** (feature-gated, zero cost when off):
25//!   - `--features profiling`  → chrome://tracing JSON  → $HOME/.zshrs/trace-{PID}.json
26//!   - `--features flamegraph` → folded stacks          → $HOME/.zshrs/flame-{PID}.folded
27//!   - `--features prometheus` → metrics on :9090/metrics
28//!
29//! Call `zsh::log::init()` (defaults to `zshrs.log`) or
30//! `zsh::log::init_named("…")` once at startup. Use
31//! `tracing::{info,debug,trace,warn,error}!` everywhere. Use
32//! `#[tracing::instrument]` or `zsh::log::span!` for timed sections.
33
34use std::path::PathBuf;
35use std::sync::OnceLock;
36use tracing_subscriber::prelude::*;
37
38/// Guards that must live for the duration of the process.
39/// Dropping any of these flushes and stops the associated writer.
40struct Guards {
41    #[cfg(feature = "profiling")]
42    _chrome: tracing_chrome::FlushGuard,
43    #[cfg(feature = "flamegraph")]
44    _flame: tracing_flame::FlushGuard<std::io::BufWriter<std::fs::File>>,
45}
46
47static GUARDS: OnceLock<Guards> = OnceLock::new();
48
49/// Resolve log/profile output directory: `$ZSHRS_HOME` or
50/// `$HOME/.zshrs`.
51/// zshrs-original — no C counterpart. C zsh has no log file at all
52/// (it writes to stderr).
53pub fn log_dir() -> PathBuf {
54    if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
55        return PathBuf::from(custom);
56    }
57    dirs::home_dir()
58        .unwrap_or_else(|| PathBuf::from("/tmp"))
59        .join(".zshrs")
60}
61
62/// Resolve the full log path for the shell binary
63/// (`zshrs.log`).
64/// zshrs-original — no C counterpart.
65pub fn log_path() -> PathBuf {
66    log_dir().join("zshrs.log")
67}
68
69/// Initialize logging + optional profiling subscribers using the
70/// default `zshrs.log` filename. Equivalent to
71/// `init_named("zshrs.log")`. Safe to call multiple times — only
72/// the first call takes effect.
73/// zshrs-original — no C counterpart. C zsh's `Src/init.c`
74/// `setupshin()` doesn't open a log file.
75///
76/// Env vars:
77///   ZSHRS_LOG=debug|trace|info|warn|error  (default: info)
78pub fn init() {
79    init_named("zshrs.log");
80}
81
82/// Initialize logging + optional profiling subscribers, writing to
83/// `$ZSHRS_HOME/<filename>` (or `$HOME/.zshrs/<filename>`). Used
84/// by `zshrs-recorder` to land tracing in `zshrs-recorder.log`
85/// instead of the shell's `zshrs.log`. The daemon owns its own
86/// subscriber via `daemon/log.rs` and uses the path from
87/// `paths.log_file_name`.
88/// zshrs-original — no C counterpart.
89///
90/// Safe to call multiple times — only the first call takes effect.
91pub fn init_named(filename: &str) {
92    GUARDS.get_or_init(|| {
93        let dir = log_dir();
94        let _ = std::fs::create_dir_all(&dir);
95        let pid = std::process::id();
96
97        // --- File log layer (always on) ---
98        // Use a blocking Mutex<File> writer — log writes are microseconds and this
99        // guarantees data reaches disk even when std::process::exit() skips destructors.
100        // c:Src/utils.c:1990 movefd — the shell's own descriptors must not sit
101        // in the script's fd range. Without this the log landed on fd 3, so
102        // `print -u 3 -r -- x` appended to it (and reported success), and
103        // `exec 3>file` would dup2 over the live log handle.
104        let _lowfd = crate::lowfd::LowFdGuard::new();
105        // Size-capped rotation at init (client-side, no background
106        // threads — the daemon owns scheduled rotation, but daemonless
107        // invocations must still bound the file). Millions of short
108        // `-fc` runs (parity fuzz harnesses) each append startup lines;
109        // unrotated this reached 18 GB / 142M lines and exhausted the
110        // disk. One stat() per shell start; over the cap the current
111        // file becomes `<name>.1` (previous `.1` is replaced) and a
112        // fresh file starts. rename() keeps writers on the old inode
113        // consistent (they finish writing to `.1`).
114        const LOG_ROTATE_BYTES: u64 = 512 * 1024 * 1024;
115        let log_path = dir.join(filename);
116        if std::fs::metadata(&log_path)
117            .map(|m| m.len() > LOG_ROTATE_BYTES)
118            .unwrap_or(false)
119        {
120            let _ = std::fs::rename(&log_path, dir.join(format!("{filename}.1")));
121        }
122        // Logging is diagnostic infrastructure and must NEVER be able to stop
123        // the shell from starting. This used to `.expect("cannot open any log
124        // file")`, so an unwritable `$HOME` (unmounted NFS, read-only image,
125        // `su` to an account whose home is absent, a container with no home)
126        // killed zshrs outright where zsh starts fine.
127        //
128        // The fallback is also per-user. It was a fixed `/tmp/zshrs.log`,
129        // which in a shared /tmp is owned by whoever ran zshrs first — every
130        // other user then hit EACCES and, before this, the panic. A fixed name
131        // in a world-writable directory is equally a squatting target: a
132        // pre-created symlink there would have been appended to. `$TMPDIR` is
133        // per-user on macOS, and the uid suffix keeps it unambiguous elsewhere.
134        let tmp_dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
135        let uid = unsafe { libc::getuid() };
136        let log_file = std::fs::OpenOptions::new()
137            .create(true)
138            .append(true)
139            .open(&log_path)
140            .or_else(|_| {
141                std::fs::OpenOptions::new()
142                    .create(true)
143                    .append(true)
144                    .open(std::path::Path::new(&tmp_dir).join(format!("{filename}.{uid}")))
145            })
146            .ok();
147        // `None` => no file layer at all; the shell runs, just without a log.
148        // Same `Option<Layer>` shape the chrome/flame layers below already use.
149        let file_layer = log_file.map(|f| {
150            tracing_subscriber::fmt::layer()
151                .with_writer(std::sync::Mutex::new(f))
152                .with_ansi(false)
153                .with_target(true)
154                .with_thread_names(true)
155                .compact()
156        });
157
158        // Filter precedence:
159        //   1. $ZSHRS_LOG (env var, ad-hoc / one-shot debugging)
160        //   2. [log] level in $ZSHRS_HOME/zshrs.toml (the persistent
161        //      knob — set once, no env var needed every session)
162        //   3. "info" hard default
163        let env_filter = std::env::var("ZSHRS_LOG")
164            .unwrap_or_else(|_| crate::daemon_presence::read_log_directive());
165
166        // --- Chrome tracing layer (--features profiling) ---
167        #[cfg(feature = "profiling")]
168        let (chrome_layer, chrome_guard) = {
169            let trace_path = dir.join(format!("trace-{}.json", pid));
170            let (layer, guard) = tracing_chrome::ChromeLayerBuilder::new()
171                .file(trace_path)
172                .include_args(true)
173                .build();
174            (Some(layer), guard)
175        };
176        #[cfg(not(feature = "profiling"))]
177        let chrome_layer: Option<tracing_subscriber::layer::Identity> = None;
178
179        // --- Flamegraph layer (--features flamegraph) ---
180        #[cfg(feature = "flamegraph")]
181        let (flame_layer, flame_guard) = {
182            let flame_path = dir.join(format!("flame-{}.folded", pid));
183            let file =
184                std::fs::File::create(&flame_path).expect("cannot create flamegraph output file");
185            let writer = std::io::BufWriter::new(file);
186            let (layer, guard) = tracing_flame::FlameLayer::with_writer(writer).build();
187            (Some(layer), guard)
188        };
189        #[cfg(not(feature = "flamegraph"))]
190        let flame_layer: Option<tracing_subscriber::layer::Identity> = None;
191
192        // --- Prometheus metrics (--features prometheus) ---
193        #[cfg(feature = "prometheus")]
194        {
195            // Spawn metrics HTTP server on :9090 in background
196            let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
197            if let Err(e) = builder.with_http_listener(([127, 0, 0, 1], 9090)).install() {
198                eprintln!("zshrs: failed to start prometheus exporter: {}", e);
199            }
200        }
201
202        // --- Assemble the subscriber registry ---
203        let subscriber = tracing_subscriber::registry()
204            .with(tracing_subscriber::EnvFilter::new(&env_filter))
205            .with(file_layer)
206            .with(chrome_layer)
207            .with(flame_layer);
208
209        let _ = tracing::subscriber::set_global_default(subscriber);
210
211        // c:Src/utils.c:2007-2010 — record the log file as a descriptor
212        // the shell owns. The appender never exposes it, so it cannot be
213        // registered at the open the way `movefd` registers its result;
214        // the sweep finds it by elimination. Done here, with the guard
215        // above still held, because the sweep only inspects descriptors
216        // at or above 10 and the guard's reservations are 3-9.
217        crate::lowfd::register_internal_fds();
218
219        Guards {
220            #[cfg(feature = "profiling")]
221            _chrome: chrome_guard,
222            #[cfg(feature = "flamegraph")]
223            _flame: flame_guard,
224        }
225    });
226}
227
228/// Flush all log writers. Call before `std::process::exit()` to
229/// ensure buffered log data reaches disk — `exit()` doesn't run
230/// destructors.
231/// zshrs-original — no C counterpart. C zsh's stderr is line-
232/// buffered (or unbuffered), so it doesn't need an explicit flush
233/// step.
234pub fn flush() {
235    // The WorkerGuard flushes on drop, but we can't drop a static.
236    // Instead, give the non-blocking writer time to drain its buffer.
237    // 50ms is more than enough for any reasonable log volume.
238    std::thread::sleep(std::time::Duration::from_millis(50));
239}
240
241/// Check if the chrome-tracing profiling feature is compiled in.
242/// zshrs-original — no C counterpart. C zsh has the `zsh/zprof`
243/// module (Src/Modules/zprof.c) for function-level profiling but
244/// nothing as fine-grained as chrome:// tracing spans.
245pub fn profiling_enabled() -> bool {
246    cfg!(feature = "profiling")
247}
248
249/// Check if the flamegraph feature is compiled in.
250/// zshrs-original — no C counterpart.
251pub fn flamegraph_enabled() -> bool {
252    cfg!(feature = "flamegraph")
253}
254
255/// Check if the Prometheus metrics exporter is compiled in.
256/// zshrs-original — no C counterpart.
257pub fn prometheus_enabled() -> bool {
258    cfg!(feature = "prometheus")
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::sync::Mutex;
265
266    // Serialize env-mutating tests to avoid races. Other suites may
267    // touch ZSHRS_HOME concurrently; this lock keeps us inside ours.
268    static ENV_LOCK: Mutex<()> = Mutex::new(());
269
270    fn with_zshrs_home<F: FnOnce()>(value: Option<&str>, f: F) {
271        let _g = ENV_LOCK.lock().unwrap();
272        let prev = std::env::var_os("ZSHRS_HOME");
273        match value {
274            Some(v) => std::env::set_var("ZSHRS_HOME", v),
275            None => std::env::remove_var("ZSHRS_HOME"),
276        }
277        f();
278        match prev {
279            Some(v) => std::env::set_var("ZSHRS_HOME", v),
280            None => std::env::remove_var("ZSHRS_HOME"),
281        }
282    }
283
284    #[test]
285    fn log_dir_honors_zshrs_home_env_var() {
286        with_zshrs_home(Some("/tmp/zshrs-test-home"), || {
287            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-test-home"));
288        });
289    }
290
291    #[test]
292    fn log_path_appends_zshrs_log_filename() {
293        with_zshrs_home(Some("/tmp/zshrs-test-path"), || {
294            assert_eq!(log_path(), PathBuf::from("/tmp/zshrs-test-path/zshrs.log"));
295        });
296    }
297
298    #[test]
299    fn log_dir_falls_back_when_zshrs_home_unset() {
300        with_zshrs_home(None, || {
301            let dir = log_dir();
302            // Must end in `.zshrs` regardless of whether HOME is set
303            // (no HOME → /tmp/.zshrs).
304            assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some(".zshrs"));
305        });
306    }
307
308    #[test]
309    fn feature_flag_helpers_match_cfg() {
310        // Each helper must agree with the underlying cfg!() macro —
311        // a regression here would mean someone special-cased the
312        // helper independent of the feature.
313        assert_eq!(profiling_enabled(), cfg!(feature = "profiling"));
314        assert_eq!(flamegraph_enabled(), cfg!(feature = "flamegraph"));
315        assert_eq!(prometheus_enabled(), cfg!(feature = "prometheus"));
316    }
317
318    // ========================================================
319    // log_dir — ZSHRS_HOME precedence and fallback
320    // ========================================================
321
322    #[test]
323    fn log_dir_returns_absolute_when_zshrs_home_absolute() {
324        with_zshrs_home(Some("/var/log/zshrs"), || {
325            let d = log_dir();
326            assert!(d.is_absolute(), "log_dir should be absolute: {:?}", d);
327            assert_eq!(d, PathBuf::from("/var/log/zshrs"));
328        });
329    }
330
331    #[test]
332    fn log_dir_honors_relative_zshrs_home_verbatim() {
333        // No magic normalization — whatever ZSHRS_HOME is, log_dir
334        // returns it (with the `.zshrs` suffix replaced).
335        with_zshrs_home(Some("relative/path"), || {
336            assert_eq!(log_dir(), PathBuf::from("relative/path"));
337        });
338    }
339
340    #[test]
341    fn log_dir_distinct_from_log_path() {
342        with_zshrs_home(Some("/tmp/zshrs-distinct"), || {
343            let d = log_dir();
344            let p = log_path();
345            assert_ne!(d, p, "dir and path must differ");
346            assert_eq!(p.parent(), Some(d.as_path()));
347        });
348    }
349
350    #[test]
351    fn log_path_filename_is_zshrs_dot_log() {
352        with_zshrs_home(Some("/tmp/zshrs-fname"), || {
353            assert_eq!(
354                log_path().file_name().and_then(|s| s.to_str()),
355                Some("zshrs.log")
356            );
357        });
358    }
359
360    #[test]
361    fn log_dir_overrides_persist_for_repeated_calls() {
362        // Calling twice in sequence should return identical values
363        // — confirms there is no hidden caching that pins the first
364        // call's result.
365        with_zshrs_home(Some("/tmp/zshrs-cache-1"), || {
366            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-cache-1"));
367        });
368        with_zshrs_home(Some("/tmp/zshrs-cache-2"), || {
369            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-cache-2"));
370        });
371    }
372
373    #[test]
374    fn log_dir_empty_zshrs_home_is_taken_verbatim() {
375        // env::var_os returns Some("") for empty — code path uses
376        // PathBuf::from("") which still gets returned. Verify no
377        // panic / silent fallback.
378        with_zshrs_home(Some(""), || {
379            assert_eq!(log_dir(), PathBuf::from(""));
380        });
381    }
382
383    #[test]
384    fn log_dir_fallback_path_ends_with_dot_zshrs_filename_component() {
385        with_zshrs_home(None, || {
386            let d = log_dir();
387            assert_eq!(d.file_name().and_then(|s| s.to_str()), Some(".zshrs"));
388        });
389    }
390
391    #[test]
392    fn log_path_inside_zshrs_home_directory() {
393        with_zshrs_home(Some("/tmp/zshrs-inside"), || {
394            let p = log_path();
395            assert!(
396                p.starts_with("/tmp/zshrs-inside"),
397                "log_path should live inside ZSHRS_HOME: {:?}",
398                p
399            );
400        });
401    }
402
403    // ========================================================
404    // Feature-flag helpers — interaction matrix
405    // ========================================================
406
407    #[test]
408    fn feature_helpers_are_stable_within_one_call() {
409        // Two back-to-back calls produce identical output (no random
410        // toggling). Trivial sanity check, catches any future
411        // accidental side-effect inside the helpers.
412        assert_eq!(profiling_enabled(), profiling_enabled());
413        assert_eq!(flamegraph_enabled(), flamegraph_enabled());
414        assert_eq!(prometheus_enabled(), prometheus_enabled());
415    }
416
417    #[test]
418    fn flush_is_idempotent_and_returns_unit() {
419        // `flush` sleeps ~50ms but must always return cleanly. Two
420        // back-to-back calls should still be safe.
421        flush();
422        flush();
423    }
424}