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}