use std::io; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use tracing_appender::non_blocking; use tracing_subscriber::fmt::format::FmtSpan; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; // Set once on first init_tracing; doubles as the init-once gate. static LOG_FILE_PATH: OnceLock = OnceLock::new(); static CRASH_HOOKS: OnceLock<()> = OnceLock::new(); fn write_crash_report(header: &str, body: &str) { let msg = format!( "\n=== CRASH (this might NOT BE fff related) {} ===\n{}\n=== CRASH END {} ===\n", header, body, header ); let _ = std::io::Write::write_all(&mut std::io::stderr(), msg.as_bytes()); if let Some(path) = LOG_FILE_PATH.get() { let _ = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) .and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes())); } } // SIGSEGV handler writes a banner to a pre-opened fd (open(2) inside a signal // handler is unsafe due to path-resolution allocs). Unix only. #[cfg(unix)] mod sigsegv { use std::os::fd::IntoRawFd; use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; static LOG_FD: AtomicI32 = AtomicI32::new(-1); // Must `create(true)` — this runs before init_tracing opens/creates the // writer file, so an append-only open on a non-existent path silently // fails, LOG_FD stays -1, and the SIGSEGV banner never reaches the log. pub fn set_log_fd(path: &Path) { if let Ok(file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) { let prev = LOG_FD.swap(file.into_raw_fd(), Ordering::Relaxed); if prev >= 0 { unsafe { libc::close(prev) }; } } } // Body must be async-signal-safe: write(2), atomic load, signal(2). Nothing else. fn handler(_info: &libc::siginfo_t) { const BANNER: &[u8] = b"\n=== CRASH SIGSEGV (fff) ===\n\ fff.nvim's rust extension hit a segfault and is about to die.\n\ Please file the bug at https://github.com/dmtrKovalenko/fff/issues with this banner attached.\n\ === CRASH END SIGSEGV ===\n"; unsafe { libc::write(2, BANNER.as_ptr().cast(), BANNER.len()); let log_fd = LOG_FD.load(Ordering::Relaxed); if log_fd >= 0 { libc::write(log_fd, BANNER.as_ptr().cast(), BANNER.len()); } // Reset to default so handler return → kernel kills us instead of // re-running the faulting instruction in an infinite loop. libc::signal(libc::SIGSEGV, libc::SIG_DFL); } } pub fn install() { // signal-hook-registry chains to LuaJIT's prior handler automatically. unsafe { let _ = signal_hook_registry::register_unchecked(libc::SIGSEGV, handler); } } } #[cfg(not(unix))] mod sigsegv { use std::path::Path; pub fn set_log_fd(_path: &Path) {} pub fn install() {} } pub fn install_panic_hook() { CRASH_HOOKS.get_or_init(install_crash_hooks); } fn install_crash_hooks() { let default_panic = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() { s.to_string() } else if let Some(s) = panic_info.payload().downcast_ref::() { s.clone() } else { "Unknown panic payload".to_string() }; let location = panic_info .location() .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) .unwrap_or_else(|| "unknown location".to_string()); tracing::error!( panic.message = %message, panic.location = %location, "PANIC occurred in FFF" ); write_crash_report( "RUST PANIC", &format!("Message: {}\nLocation: {}", message, location), ); default_panic(panic_info); })); sigsegv::install(); } /// Parse a log level string into a `tracing::Level`. pub fn parse_log_level(level: Option<&str>) -> tracing::Level { match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() { Some("trace") => tracing::Level::TRACE, Some("debug") => tracing::Level::DEBUG, Some("info") => tracing::Level::INFO, Some("warn") => tracing::Level::WARN, Some("error") => tracing::Level::ERROR, _ => tracing::Level::INFO, } } /// Default retention: how many prior nvim sessions' log files to keep. const DEFAULT_RETAIN_RUNS: usize = 20; pub fn generate_trace_id() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static TRACE_COUNTER: AtomicU64 = AtomicU64::new(0); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let pid = std::process::id() as u64; let counter = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed); // very simple hash functions helps to distinguish trace ids visually let id = nanos ^ (pid.wrapping_mul(0x9E37_79B9_7F4A_7C15)) ^ (counter << 32); format!("{:016x}", id) } pub fn trace_span(trace_id: &str, label: &'static str) -> tracing::Span { tracing::info_span!("fff.trace", trace_id = trace_id, label = label) } fn unix_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn session_path_from_hint(hint: &Path) -> PathBuf { let stem = hint.file_stem().and_then(|s| s.to_str()).unwrap_or("fff"); let ext = hint.extension().and_then(|e| e.to_str()).unwrap_or("log"); let parent = hint.parent().unwrap_or_else(|| Path::new(".")); parent.join(format!( "{stem}+{ts}+{pid}.{ext}", ts = unix_secs(), pid = std::process::id(), )) } fn rotate_logs(dir: &Path, stem: &str, ext: &str, retain_runs: usize) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; let prefix = format!("{stem}+"); let suffix = format!(".{ext}"); let mut files: Vec<(std::time::SystemTime, PathBuf)> = entries .filter_map(|res| { let entry = res.ok()?; let name = entry.file_name(); let name = name.to_str()?; if !name.starts_with(&prefix) || !name.ends_with(&suffix) { return None; } let mtime = entry.metadata().ok()?.modified().ok()?; Some((mtime, entry.path())) }) .collect(); if files.len() <= retain_runs { return; } // Newest first, then drop everything past retain_runs. files.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); for (_, path) in files.into_iter().skip(retain_runs) { let _ = std::fs::remove_file(path); } } /// `log_file_path` is a path-shape hint. Each call writes a unique sibling /// `++.` so concurrent processes never collide. /// Returns the absolute path of the session file. pub fn init_tracing( log_file_path: &str, log_level: Option<&str>, retain_runs: Option, ) -> Result { let hint = Path::new(log_file_path); let session_dir = hint .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); std::fs::create_dir_all(&session_dir)?; let session_path = session_path_from_hint(hint); // First init wins; repeat callers no-op and return the original path. if LOG_FILE_PATH.set(session_path.clone()).is_err() { return Ok(LOG_FILE_PATH .get() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default()); } sigsegv::set_log_fd(&session_path); install_panic_hook(); let stem = hint.file_stem().and_then(|s| s.to_str()).unwrap_or("fff"); let ext = hint.extension().and_then(|e| e.to_str()).unwrap_or("log"); rotate_logs( &session_dir, stem, ext, retain_runs.unwrap_or(DEFAULT_RETAIN_RUNS), ); let writer_file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&session_path)?; // we intinionally leark the guard we don't ever want to stop logging let (non_blocking_appender, guard) = non_blocking(writer_file); Box::leak(Box::new(guard)); let subscriber = tracing_subscriber::registry() .with( fmt::layer() .with_writer(non_blocking_appender) .with_target(true) .with_thread_ids(false) .with_thread_names(true) .with_ansi(false) .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), ) .with( EnvFilter::builder() .with_default_directive(parse_log_level(log_level).into()) .from_env_lossy(), ); if let Err(e) = tracing::subscriber::set_global_default(subscriber) { eprintln!("Failed to set tracing subscriber: {}", e); } else { tracing::info!( "FFF tracing initialized: {} (pid={}, retain_runs={})", session_path.display(), std::process::id(), retain_runs.unwrap_or(DEFAULT_RETAIN_RUNS), ); } Ok(session_path.to_string_lossy().into_owned()) }