Skip to main content

xet_runtime/logging/
init.rs

1#[cfg(not(target_family = "wasm"))]
2use std::ffi::OsStr;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6#[cfg(not(target_family = "wasm"))]
7use std::sync::OnceLock;
8use std::thread::JoinHandle;
9use std::time::Duration;
10
11use chrono::{DateTime, FixedOffset, Local, Utc};
12use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
13use tracing::{debug, error, info, warn};
14#[cfg(not(target_family = "wasm"))]
15use tracing_appender::{non_blocking, rolling};
16use tracing_subscriber::layer::SubscriberExt;
17use tracing_subscriber::util::SubscriberInitExt;
18use tracing_subscriber::{EnvFilter, Layer};
19
20use super::config::*;
21use super::constants::DEFAULT_LOG_LEVEL_CONSOLE;
22#[cfg(not(target_family = "wasm"))]
23use super::constants::DEFAULT_LOG_LEVEL_FILE;
24use crate::error_printer::ErrorPrinter;
25use crate::utils::ByteSize;
26
27/// Global variable to hold the JoinHandle for the log cleanup thread
28static LOG_CLEANUP_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
29
30/// Wait for the log directory cleanup to complete.
31/// This function blocks until the background cleanup thread finishes.
32pub fn wait_for_log_directory_cleanup() {
33    if let Ok(mut handle_opt) = LOG_CLEANUP_HANDLE.lock()
34        && let Some(handle) = handle_opt.take()
35    {
36        let _ = handle.join();
37    }
38}
39
40/// The main entry point to set up logging.  Should only be called once.
41pub fn init(cfg: LoggingConfig) {
42    let mut dir_cleanup_task = None;
43
44    let maybe_log_file: Option<PathBuf> = {
45        match &cfg.logging_mode {
46            LoggingMode::Directory(log_dir) => {
47                if cfg.enable_log_dir_cleanup && log_dir.exists() && log_dir.is_dir() {
48                    dir_cleanup_task =
49                        Some(|| run_log_directory_cleanup_background(cfg.log_dir_config.clone(), log_dir));
50                }
51
52                Some(log_file_in_dir(&cfg.log_dir_config, log_dir))
53            },
54            LoggingMode::File(path_buf) => Some(path_buf.clone()),
55            LoggingMode::Console => None,
56        }
57    };
58
59    // Set up either logging to console or to a log file.
60    if let Some(log_file) = maybe_log_file {
61        // Attempt logging to a file, but fallback to console logging on error.
62        if let Err(e) = init_logging_to_file(&log_file, cfg.use_json) {
63            init_logging_to_console(&cfg);
64            error!("Error logging to file {log_file:?} ({e}); falling back to console logging.");
65        }
66    } else {
67        init_logging_to_console(&cfg);
68    }
69
70    // Log the version information.
71    info!("{}, xet-core revision {}", &cfg.version, git_version::git_version!(fallback = "unknown"));
72
73    // Log the memory-derived download buffer defaults. This must happen after the
74    // subscriber is installed: the values themselves are typically computed while
75    // building the LoggingConfig (XetConfig::new), when no subscriber exists yet.
76    crate::utils::system_memory::log_derived_defaults();
77
78    if let Some(dir_cleanup_task_fn) = dir_cleanup_task {
79        dir_cleanup_task_fn();
80    }
81}
82
83fn init_logging_to_console(cfg: &LoggingConfig) {
84    // Now, just use basic console logging.
85    let registry = tracing_subscriber::registry();
86
87    #[cfg(feature = "tokio-console")]
88    let registry = {
89        // Console subscriber layer for tokio-console, custom filter for tokio trace level events
90        let console_layer = console_subscriber::spawn().with_filter(EnvFilter::new("tokio=trace,runtime=trace"));
91        registry.with(console_layer)
92    };
93
94    let fmt_layer_base = tracing_subscriber::fmt::layer()
95        .with_line_number(true)
96        .with_file(true)
97        .with_target(false);
98    let fmt_filter = EnvFilter::try_from_default_env()
99        .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_LEVEL_CONSOLE))
100        .unwrap_or_default();
101
102    if cfg.use_json {
103        let filtered_fmt_layer = fmt_layer_base.json().with_filter(fmt_filter);
104        registry.with(filtered_fmt_layer).init();
105    } else {
106        let filtered_fmt_layer = fmt_layer_base.pretty().with_filter(fmt_filter);
107        registry.with(filtered_fmt_layer).init();
108    }
109}
110
111#[cfg(not(target_family = "wasm"))]
112fn init_logging_to_file(path: &Path, use_json: bool) -> Result<(), std::io::Error> {
113    // Set up logging to a file.
114    let (path, file_name) = match path.file_name() {
115        Some(name) => (path.to_path_buf(), name),
116        None => (path.join("xet.log"), OsStr::new("xet.log")),
117    };
118
119    let log_directory = match path.parent() {
120        Some(parent) => {
121            std::fs::create_dir_all(parent)?;
122            parent
123        },
124        None => Path::new("."),
125    };
126
127    // Make sure the log location is writeable so we error early here and dump to stderr on failure.
128    std::fs::write(&path, [])?;
129
130    // Build a non‑blocking file appender. • `rolling::never` = one static file, no rotation. • Keep the
131    // `WorkerGuard` alive so the background thread doesn’t shut down and drop messages.
132    let file_appender = rolling::never(log_directory, file_name);
133
134    let (writer, guard) = non_blocking(file_appender);
135
136    // Store the guard globally so it isn’t dropped.
137    static FILE_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
138    let _ = FILE_GUARD.set(guard); // ignore error if already initialised
139
140    let registry = tracing_subscriber::registry();
141
142    #[cfg(feature = "tokio-console")]
143    let registry = {
144        // Console subscriber layer for tokio-console, custom filter for tokio trace level events
145        let console_layer = console_subscriber::spawn().with_filter(EnvFilter::new("tokio=trace,runtime=trace"));
146        registry.with(console_layer)
147    };
148
149    // Build the fmt layer.
150    let fmt_layer_base = tracing_subscriber::fmt::layer()
151        .with_line_number(true)
152        .with_file(true)
153        .with_target(false)
154        .with_writer(writer);
155    // Standard filter layer: RUST_LOG env var or DEFAULT_LOG_LEVEL fallback.
156    let fmt_filter = EnvFilter::try_from_default_env()
157        .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_LEVEL_FILE))
158        .unwrap_or_default();
159
160    if use_json {
161        registry.with(fmt_layer_base.json().with_filter(fmt_filter)).init();
162    } else {
163        registry.with(fmt_layer_base.pretty().with_filter(fmt_filter)).init();
164    };
165
166    Ok(())
167}
168
169#[cfg(target_family = "wasm")]
170fn init_logging_to_file(_path: &Path, _use_json: bool) -> Result<(), std::io::Error> {
171    Err(io::Error::new(io::ErrorKind::Unsupported, "file logging is not supported on wasm targets"))
172}
173
174/// Build `<prefix>_<YYYYMMDD>T<HHMMSS><mmm><+/-HHMM>_<pid>.log` in `dir`.
175/// Timestamp is in *local time with numeric offset* (e.g., -0700), filename-safe.
176pub fn log_file_in_dir(cfg: &LogDirConfig, dir: impl AsRef<Path>) -> PathBuf {
177    let now_local: DateTime<Local> = Local::now();
178    let now_fixed: DateTime<FixedOffset> = now_local.with_timezone(now_local.offset());
179
180    // ISO 8601 basic, filename-safe (no colons): 20250915T083210123-0700
181    let ts = now_fixed.format("%Y%m%dT%H%M%S%3f%z"); // %z => ±HHMM
182
183    let pid = std::process::id();
184    let prefix = &cfg.filename_prefix;
185    let filename = format!("{}_{}_{}.log", prefix, ts, pid);
186    dir.as_ref().join(filename)
187}
188
189/// Parse `<prefix>_<YYYYMMDD>T<HHMMSS><mmm><+/-HHMM>_<pid>.log`
190/// Works with full paths or bare filenames.
191/// Returns (prefix, timestamp with fixed offset, pid).
192pub fn parse_log_file_name(path: impl AsRef<Path>) -> Option<(String, DateTime<FixedOffset>, u32)> {
193    let path = path.as_ref();
194    let file_name = path.file_name()?.to_str()?;
195
196    // Returns None if it doesn't end with .log.
197    let file_name = file_name.strip_suffix(".log")?;
198
199    // Split from the RIGHT so base may contain underscores.
200    // Expect exactly: <base>_<timestamp>_<pid>
201    let mut parts = file_name.rsplitn(3, '_');
202    let pid_str = parts.next()?;
203    let ts_str = parts.next()?;
204    let prefix = parts.next()?; // remainder is the full base (may include underscores)
205
206    let pid: u32 = pid_str.parse().ok()?;
207
208    // Parse ISO 8601-basic with offset, no colons
209    let ts = DateTime::parse_from_str(ts_str, "%Y%m%dT%H%M%S%3f%z").ok()?;
210
211    Some((prefix.to_string(), ts, pid))
212}
213
214// A utility struct to help with the directory cleanup.
215struct CandidateLogFile {
216    path: PathBuf,
217    size: u64,
218    age: Duration,
219}
220
221/// Returns true if a running process with `pid` plausibly owns the log file named with
222/// `log_timestamp` (embedded in the filename at creation time).
223///
224/// PIDs are recycled on Windows and other OSes; [`System::process`] alone would treat a
225/// stale log file as protected whenever an unrelated process reuses the same PID. If the
226/// process start time is clearly after the log's embedded timestamp, the PID no longer
227/// refers to the process that created this file.
228fn pid_protects_log_file(sys: &System, pid: u32, log_timestamp: DateTime<FixedOffset>) -> bool {
229    let Some(proc) = sys.process(Pid::from_u32(pid)) else {
230        return false;
231    };
232    let log_epoch_secs = log_timestamp.timestamp();
233    let proc_start_secs = i64::try_from(proc.start_time()).unwrap_or(i64::MAX);
234    if proc_start_secs > log_epoch_secs {
235        debug!("PID {pid} likely reused: process start {proc_start_secs}s > log timestamp {log_epoch_secs}s");
236        return false;
237    }
238    true
239}
240
241fn run_log_directory_cleanup_background(cfg: LogDirConfig, log_dir: &Path) {
242    // Spawn run_log_directory_cleanup as background thread, logging any errors as a warn!
243    let log_dir = log_dir.to_path_buf();
244    let handle = std::thread::spawn(move || {
245        if let Err(e) = run_log_directory_cleanup(cfg, &log_dir) {
246            warn!("Error during log directory cleanup in {:?}: {}", log_dir, e);
247        }
248    });
249
250    // Store the JoinHandle in the global variable
251    if let Ok(mut handle_opt) = LOG_CLEANUP_HANDLE.lock() {
252        debug_assert!(handle_opt.is_none(), "Log directory cleanup called multiple times.");
253        *handle_opt = Some(handle);
254    }
255}
256
257fn run_log_directory_cleanup(cfg: LogDirConfig, log_dir: &Path) -> io::Result<()> {
258    info!(
259        "starting log cleanup in {:?} (min_age={:?}, max_retention={:?}, max_size={} bytes)",
260        log_dir,
261        cfg.min_deletion_age,
262        cfg.max_retention_age,
263        ByteSize::new(cfg.size_limit)
264    );
265
266    // Initialize sysinfo once to get a list of the active process ids.  To ensure we never delete
267    // a log file associated with an active process, we preserve any log file associated with a currently
268    // active PID.
269    let sys = System::new_with_specifics(RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()));
270
271    // Collect candidate files.
272    let mut candidates = Vec::<CandidateLogFile>::new();
273    let mut total_bytes: u64 = 0;
274    let mut candidate_deletion_bytes: u64 = 0;
275
276    let now = Utc::now();
277    let mut n_log_files = 0usize;
278
279    for entry in std::fs::read_dir(log_dir)? {
280        let Ok(entry) = entry.warn_error_fn(|| format!("read_dir error while reading {log_dir:?}")) else {
281            continue;
282        };
283
284        let path = entry.path();
285
286        let Ok(ft) = entry.file_type() else { continue };
287        if !ft.is_file() {
288            continue;
289        }
290
291        let Some((prefix, timestamp, pid)) = parse_log_file_name(&path) else {
292            debug!("ignoring unparseable log file {:?}", path);
293            continue;
294        };
295
296        if prefix != cfg.filename_prefix {
297            debug!("ignoring log file {:?} with differing prefix {prefix} (!={})", path, &cfg.filename_prefix);
298            continue;
299        }
300
301        // Only use info here as it could be another process deleted it.
302        let Ok(meta) = entry
303            .metadata()
304            .info_error_fn(|| format!("Reading metadata failed for {:?}", path))
305        else {
306            continue;
307        };
308
309        let size = meta.len();
310        total_bytes += size;
311        n_log_files += 1;
312
313        let Ok(age) = (now - timestamp.to_utc()).to_std() else {
314            debug!("Skipping deletion for very new log file {path:?}");
315            continue;
316        };
317
318        // Skip if it's too new.
319        if age < cfg.min_deletion_age {
320            debug!("Skipping deletion for new log file {path:?}");
321            continue;
322        }
323
324        // Skip if there is an active PID associated with the file (and not PID reuse).
325        if pid_protects_log_file(&sys, pid, timestamp) {
326            debug!("Skipping deletion for log file {path:?} with active associated PID.");
327            continue;
328        }
329
330        // These files are available for deletion.
331        candidates.push(CandidateLogFile { path, size, age });
332
333        candidate_deletion_bytes += size;
334    }
335
336    info!(
337        "Log Directory Cleanup: found {:?} of logs in {} log files, with {:?} in {} files eligible for deletion.",
338        ByteSize::new(total_bytes),
339        n_log_files,
340        ByteSize::new(candidate_deletion_bytes),
341        candidates.len()
342    );
343
344    // 1) Hard expiration pass: delete anything older than max_retention, unless protected.
345    let mut deleted_bytes: u64 = 0;
346    candidates.retain(|lf| {
347        if lf.age > cfg.max_retention_age {
348            let path = &lf.path;
349            match std::fs::remove_file(path) {
350                Ok(_) => {
351                    deleted_bytes += lf.size;
352                    debug!("Log Directory Cleanup: Removed old log file {path:?})");
353                },
354                Err(e) => {
355                    // If the error is because the file no longer exists, then count it towards the deleted bytes;
356                    // otherwise log and skip.
357                    if e.kind() == io::ErrorKind::NotFound {
358                        deleted_bytes += lf.size;
359                        debug!("Log Directory Cleanup: Old log file {path:?} already deleted.");
360                    } else {
361                        info!("Log Directory Cleanup: Error removing old log file {path:?}, skipping: {e}");
362                    }
363                },
364            };
365            false
366        } else {
367            true
368        }
369    });
370
371    // 2) Size trimming: if above the limit, delete oldest eligible (unprotected) first.
372    let mut n_pruned = 0;
373    if total_bytes - deleted_bytes > cfg.size_limit {
374        // Sort by oldest first.
375        candidates.sort_by_key(|lf| std::cmp::Reverse(lf.age));
376        for lf in &candidates {
377            if total_bytes - deleted_bytes <= cfg.size_limit {
378                break;
379            }
380
381            match std::fs::remove_file(&lf.path) {
382                Ok(()) => {
383                    deleted_bytes += lf.size;
384                    n_pruned += 1;
385                    debug!("Log Directory cleanup: Pruned log file {:?}.", lf.path);
386                },
387                Err(e) => {
388                    if e.kind() == io::ErrorKind::NotFound {
389                        deleted_bytes += lf.size;
390                        n_pruned += 1;
391                        debug!("Log Directory cleanup: Log file {:?} already deleted, ignoring.", lf.path);
392                    } else {
393                        info!("Log Directory Cleanup: Error removing size-pruned log file {:?}: {}", lf.path, e);
394                    }
395                },
396            }
397        }
398    }
399
400    info!(
401        "Log Directory Cleanup: deleted {:?} in {} files",
402        ByteSize::new(deleted_bytes),
403        candidates.len() - n_pruned
404    );
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410
411    use chrono::{Datelike, Timelike};
412
413    use super::*;
414
415    #[test]
416    fn round_trip_make_and_parse() {
417        let dir = Path::new("/tmp");
418        let cfg = LogDirConfig::from_config(&crate::config::XetConfig::new());
419        let path = log_file_in_dir(&cfg, dir);
420        let (base, ts, pid) = parse_log_file_name(&path).expect("parse");
421        assert_eq!(base, cfg.filename_prefix);
422        assert!(pid > 0);
423
424        // Verify that the timestamp string matches what's embedded in the filename
425        let fname = path.file_name().unwrap().to_str().unwrap();
426        let ts_part = fname
427            .strip_prefix(&format!("{}_", base))
428            .unwrap()
429            .strip_suffix(&format!("_{}.log", pid))
430            .unwrap();
431        assert_eq!(ts_part, ts.format("%Y%m%dT%H%M%S%3f%z").to_string());
432    }
433
434    #[test]
435    fn parse_known_file() {
436        let fname = "app_base_20250915T083210123-0700_12345.log";
437        let (base, ts, pid) = parse_log_file_name(fname).expect("parse");
438        assert_eq!(base, "app_base");
439        assert_eq!(pid, 12345);
440        assert_eq!(ts.format("%Y%m%dT%H%M%S%3f%z").to_string(), "20250915T083210123-0700");
441        assert_eq!(ts.year(), 2025);
442        assert_eq!(ts.month(), 9);
443        assert_eq!(ts.day(), 15);
444        assert_eq!(ts.hour(), 8);
445        assert_eq!(ts.minute(), 32);
446        assert_eq!(ts.second(), 10);
447        assert_eq!(ts.timestamp_subsec_millis(), 123);
448        assert_eq!(ts.offset().local_minus_utc(), -7 * 3600);
449    }
450
451    #[test]
452    fn allows_underscores_in_base() {
453        let fname = "my_cool_app_20240102T030405006+0530_999.log";
454        let (base, ts, pid) = parse_log_file_name(fname).expect("parse");
455        assert_eq!(base, "my_cool_app");
456        assert_eq!(pid, 999);
457        assert_eq!(ts.format("%Y%m%dT%H%M%S%3f%z").to_string(), "20240102T030405006+0530");
458    }
459
460    #[test]
461    fn parse_with_directory_path() {
462        let path = Path::new("/var/log/myprog/app_20250915T083210123-0700_12345.log");
463        let (base, _, pid) = parse_log_file_name(path).expect("parse");
464        assert_eq!(base, "app");
465        assert_eq!(pid, 12345);
466    }
467}