diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..3813ba85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,58 @@ +name: Bug report +description: Report a crash, hang, or incorrect behavior in fff (any frontend — nvim plugin, Node/Bun SDK, MCP server, C SDK). +title: "[Bug]: " +labels: ["bug"] +body: + - type: dropdown + id: frontend + attributes: + label: Which fff frontend? + options: + - Neovim plugin (fff.nvim) + - MCP server (fff-mcp) + - Node SDK (@ff-labs/fff-node) + - Bun SDK + - C SDK (libfff) + - Other / multiple + validations: + required: true + + - type: textarea + id: logs + attributes: + label: has logs + description: | + Attach your fff log file — the single most useful thing for debugging. + + fff writes a fresh log file on every process startup, named `fff++.log`, and keeps the last 20. Find the file matching your crashed/buggy run and drag-and-drop it here (or paste its contents). + + Where the log files live: + + | Frontend | Linux / macOS | Windows | + |---|---|---| + | Neovim plugin | `~/.local/state/nvim/log/fff+*.log` | `%LOCALAPPDATA%\nvim-data\log\fff+*.log` | + | MCP server (`fff-mcp`) | `~/.cache/fff_mcp+*.log` (override with `--log-file`) | `%LOCALAPPDATA%\fff_mcp+*.log` | + | Node / Bun SDK | path you passed as `logFilePath` to `FileFinder.create({...})` | same | + | C SDK | path you passed as `log_file_path` in `FffCreateOptions` | same | + + Neovim users: run `:FFFOpenLog` to open the current session's log directly. + + Also some useful commands: + + ```sh + # Neovim plugin + ls -t ~/.local/state/nvim/log/fff+*.log | head -1 + + # MCP server + ls -t ~/.cache/fff_mcp+*.log | head -1 + ``` + validations: + required: false + + - type: textarea + id: body + attributes: + label: Description + description: Please provide as much helpful information as you can + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0d3dd562 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discussion / question + url: https://github.com/dmtrKovalenko/fff/discussions + about: For usage questions, design discussion, or anything that's not a bug or feature request. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..caa70e05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature request +description: Suggest something new for fff +title: "[Suggestion]: " +labels: ["enhancement"] +body: + - type: dropdown + id: frontend + attributes: + label: Which fff frontend(s)? + multiple: true + options: + - Neovim plugin (fff.nvim) + - MCP server (fff-mcp) + - Node SDK (@ff-labs/fff-node) + - Bun SDK (@ff-labs/fff-bun) + - C lib (libfff) + - Core or Rust crate + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: If you have an idea of the shape of the API, describe it here. + validations: + required: false diff --git a/Cargo.lock b/Cargo.lock index 4c6b1693..a3707bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,6 +741,7 @@ dependencies = [ "regex", "regex-syntax", "serde", + "signal-hook-registry", "smallvec", "tempfile", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 7ebaabd6..bd49b869 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ heed = "0.22.0" ignore = "0.4.22" memmap2 = "0.9" mimalloc = "0.1.47" +signal-hook-registry = "1.4" zlob = "1.4.1" mlua = { version = "0.11.1", features = ["module", "luajit"] } @@ -50,7 +51,7 @@ tracing = "0.1" opt-level = 3 lto = "fat" codegen-units = 1 -strip = true +strip = "debuginfo" [profile.ci] inherits = "release" diff --git a/README.md b/README.md index d19b5c0f..51eab555 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,12 @@ require('fff').setup({ max_threads = 4, lazy_sync = true, prompt_vim_mode = false, + follow_symlinks = false, + -- Allow indexing the user's $HOME directory. Enabled by default. + -- Disable if you strictly sure you don't want this, as it makes whole fff error hard + enable_home_dir_scanning = true, + -- Allow indexing a filesystem root (e.g. `/`, `C:\`). Disabled by default + enable_fs_root_scanning = false, layout = { height = 0.8, width = 0.8, @@ -340,9 +346,11 @@ require('fff').setup({ }, }, logging = { - enabled = true, + -- logs will be written in a parent directory of this file path in files like + -- `++.`. Run :FFFOpenLog to open current one log_file = vim.fn.stdpath('log') .. '/fff.log', log_level = 'info', + retain_runs = 20, }, }) ``` @@ -442,7 +450,9 @@ Run `:FFFScan` to force a rescan. ### Troubleshooting - `:FFFHealth` verifies picker init, optional dependencies, and DB connectivity. -- `:FFFOpenLog` opens the log file. +- `:FFFOpenLog` opens the current session's log file. +- Historical log files are stored near the main log file `/log/fff++.log` (up to 20 files) +- For a crash backtrace, run `lldb -- nvim` or `gdb -- nvim` and reproduce diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index 704ffbd5..6be43935 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -100,7 +100,10 @@ typedef struct FffCreateOptions { */ bool ai_mode; /** - * Tracing log file path. NULL/empty to skip log init. + * Path-shape hint for the per-session log file. Each call writes a fresh + * sibling file `++.` next to this path. + * The literal path is never written to, so concurrent processes get + * unique per-pid files. NULL/empty to skip log init. */ const char *log_file_path; /** diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index be76416c..0e0d0c91 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -228,7 +228,7 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) if let Some(log_path) = unsafe { optional_cstr(opts.log_file_path) } { let level = unsafe { optional_cstr(opts.log_level) }; - if let Err(e) = fff::log::init_tracing(log_path, level) { + if let Err(e) = fff::log::init_tracing(log_path, level, None) { return FffResult::err(&format!("Failed to init tracing: {}", e)); } } diff --git a/crates/fff-core/Cargo.toml b/crates/fff-core/Cargo.toml index 5166994b..f3117dc2 100644 --- a/crates/fff-core/Cargo.toml +++ b/crates/fff-core/Cargo.toml @@ -74,6 +74,10 @@ mimalloc = { version = "0.1", optional = true, features = ["local_dynamic_tls"] [target.'cfg(windows)'.dependencies] dunce = { workspace = true } +# signal-hook only compiles on unix; we wrap the SIGSEGV handler behind cfg(unix) +[target.'cfg(unix)'.dependencies] +signal-hook-registry = { workspace = true } + [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } ctor = "0.2" diff --git a/crates/fff-core/src/background_watcher.rs b/crates/fff-core/src/background_watcher.rs index d38ffe5a..687bb17c 100644 --- a/crates/fff-core/src/background_watcher.rs +++ b/crates/fff-core/src/background_watcher.rs @@ -35,6 +35,7 @@ const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096; const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60; impl BackgroundWatcher { + #[allow(clippy::too_many_arguments)] pub fn new( base_path: PathBuf, git_workdir: Option, @@ -43,6 +44,7 @@ impl BackgroundWatcher { mode: FFFMode, enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, + trace_span: tracing::Span, ) -> Result { info!( "Initializing background watcher for path: {}, mode: {:?}", @@ -103,9 +105,11 @@ impl BackgroundWatcher { #[cfg(target_os = "linux")] let owner_debouncer = Arc::clone(&debouncer); + let owner_span = trace_span.clone(); let owner_thread = std::thread::Builder::new() .name("fff-watcher-own".into()) .spawn(move || { + let _g = owner_span.enter(); while let Ok(dir) = watch_rx.recv() { // if the picker is dropped we do need to exit the loop let Some(strong_picker) = owner_weak_picker.upgrade() else { diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 04692e2a..ff91be56 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -446,6 +446,8 @@ pub struct FilePicker { follow_symlinks: bool, enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, + trace_span: tracing::Span, + trace_id: String, } impl std::fmt::Debug for FilePicker { @@ -511,6 +513,14 @@ impl FilePicker { self.enable_home_dir_scanning } + pub fn trace_id(&self) -> &str { + &self.trace_id + } + + pub fn trace_span(&self) -> tracing::Span { + self.trace_span.clone() + } + pub fn mode(&self) -> FFFMode { self.mode } @@ -698,6 +708,9 @@ impl FilePicker { let has_explicit_budget = options.cache_budget.is_some(); let initial_budget = options.cache_budget.unwrap_or_default(); + let trace_id = crate::log::generate_trace_id(); + let trace_span = crate::log::trace_span(&trace_id, "picker"); + Ok(FilePicker { background_watcher: None, base_path: path, @@ -713,11 +726,13 @@ impl FilePicker { follow_symlinks: options.follow_symlinks, enable_fs_root_scanning: options.enable_fs_root_scanning, enable_home_dir_scanning: options.enable_home_dir_scanning, + trace_span, + trace_id, }) } /// Create a picker, place it into the shared handle, and spawn background - /// indexing + file-system watcher. This is the default entry point. + /// indexing + file-system watcgenerate_trace_id the default entry point. pub fn new_with_shared_state( shared_picker: SharedFilePicker, shared_frecency: SharedFrecency, @@ -744,13 +759,12 @@ impl FilePicker { let signals = picker.scan_signals(); let scanned_files_counter = picker.scanned_files_counter(); let path = picker.base_path.clone(); + let trace_span = picker.trace_span.clone(); { let mut guard = shared_picker.write()?; *guard = Some(picker); - // by dropping the old picker if it exists we triggering - // it's internal `cancelled` flag flip which will automatically clean - // any thread that might be capturing the reference safely & unsfaely + // dropping old picker flips its `cancelled` flag → bg threads exit cleanly } ScanJob::new_initial( @@ -760,6 +774,7 @@ impl FilePicker { mode, signals, scanned_files_counter, + trace_span, ScanConfig { warmup, content_indexing, @@ -848,6 +863,7 @@ impl FilePicker { self.mode, self.enable_fs_root_scanning, self.enable_home_dir_scanning, + self.trace_span.clone(), )?; self.background_watcher = Some(watcher); self.signals.watcher_ready.store(true, Ordering::Release); diff --git a/crates/fff-core/src/log.rs b/crates/fff-core/src/log.rs index 1cb47c32..13d3cc45 100644 --- a/crates/fff-core/src/log.rs +++ b/crates/fff-core/src/log.rs @@ -1,31 +1,20 @@ -//! Shared logging utilities for FFF crates. -//! -//! Provides file-based tracing initialization and crash handlers (panic hook -//! + SIGSEGV signal handler) that write diagnostics to both stderr and the -//! configured log file. - 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::*}; -static TRACING_INITIALIZED: std::sync::OnceLock = - std::sync::OnceLock::new(); - -static CRASH_HANDLERS_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); - -/// The log file path set by `init_tracing`. Crash handlers append to this file. -static LOG_FILE_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +// 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 {} ===\n{}\n=== CRASH END {} ===\n", + "\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) @@ -35,54 +24,92 @@ fn write_crash_report(header: &str, body: &str) { } } -extern "C" fn sigsegv_handler(sig: libc::c_int) { - let bt = std::backtrace::Backtrace::force_capture(); - write_crash_report("SIGSEGV", &format!("signal {}\n{}", sig, bt)); +// 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); - unsafe { - libc::signal(sig, libc::SIG_DFL); - libc::raise(sig); + pub fn set_log_fd(path: &Path) { + if let Ok(file) = std::fs::OpenOptions::new().append(true).open(path) { + let prev = LOG_FD.swap(file.into_raw_fd(), Ordering::Relaxed); + if prev >= 0 { + unsafe { libc::close(prev) }; + } + } } -} -/// Install both the panic hook and the SIGSEGV signal handler. -pub fn install_panic_hook() { - CRASH_HANDLERS_INSTALLED.get_or_init(|| { - 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); - })); + // 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 { - libc::signal( - libc::SIGSEGV, - sigsegv_handler as *const () as libc::sighandler_t, - ); + 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`. @@ -97,54 +124,151 @@ pub fn parse_log_level(level: Option<&str>) -> tracing::Level { } } -/// Initialize tracing with a single log file. -pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result { - let log_path = Path::new(log_file_path); - if let Some(parent) = log_path.parent() { - std::fs::create_dir_all(parent)?; +/// 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); - let _ = LOG_FILE_PATH.set(log_path.to_path_buf()); + // 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 file_appender = std::fs::OpenOptions::new() + 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) - .write(true) - .truncate(true) // truncates a file on restart (instead of appending) - .open(log_path)?; - - let level = parse_log_level(log_level); - - TRACING_INITIALIZED.get_or_init(|| { - let (non_blocking_appender, guard) = non_blocking(file_appender); - - let subscriber = tracing_subscriber::registry() - .with( - fmt::layer() - .with_writer(non_blocking_appender) - .with_target(true) - .with_thread_ids(false) - .with_thread_names(false) - .with_ansi(false) - .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), - ) - .with( - EnvFilter::builder() - .with_default_directive(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 with log file: {}", - log_path.display() - ); - } + .append(true) + .open(&session_path)?; - guard - }); + // 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(log_file_path.to_string()) + Ok(session_path.to_string_lossy().into_owned()) } diff --git a/crates/fff-core/src/scan.rs b/crates/fff-core/src/scan.rs index d39073c0..a762a95f 100644 --- a/crates/fff-core/src/scan.rs +++ b/crates/fff-core/src/scan.rs @@ -61,6 +61,7 @@ pub(crate) struct ScanJob { /// side. Reset to 0 at scan start, incremented per-file by the /// walker. Shared `Arc` so the UI polls the same atomic. scanned_files_counter: Arc, + trace_span: tracing::Span, } impl ScanJob { @@ -84,6 +85,7 @@ impl ScanJob { let signals = picker.scan_signals(); let scanned_files_counter = picker.scanned_files_counter(); let base_path = picker.base_path().to_path_buf(); + let trace_span = picker.trace_span(); let new_scan_config = ScanConfig { warmup: picker.has_mmap_cache(), @@ -106,9 +108,11 @@ impl ScanJob { config: new_scan_config, shared_picker: shared_picker.clone(), shared_frecency: shared_frecency.clone(), + trace_span, })) } + #[allow(clippy::too_many_arguments)] pub fn new_initial( shared_picker: SharedFilePicker, shared_frecency: SharedFrecency, @@ -116,6 +120,7 @@ impl ScanJob { mode: FFFMode, signals: ScanSignals, scanned_files_counter: Arc, + trace_span: tracing::Span, config: ScanConfig, ) -> Self { Self { @@ -126,15 +131,20 @@ impl ScanJob { signals, scanned_files_counter, config, + trace_span, } } /// Spawn the job on a dedicated OS thread. Returns immediately. pub fn spawn(self) -> std::thread::JoinHandle<()> { self.signals.scanning.store(true, Ordering::Release); + let span = self.trace_span.clone(); std::thread::Builder::new() .name("fff-scan".into()) - .spawn(move || self.run()) + .spawn(move || { + let _g = span.enter(); + self.run(); + }) .expect("failed to spawn fff-scan thread") } @@ -147,6 +157,7 @@ impl ScanJob { signals, scanned_files_counter, config, + trace_span: _, } = self; let _scanning = ScanningGuard::new(&signals, config.install_watcher); @@ -250,6 +261,7 @@ impl ScanJob { mode, config.enable_fs_root_scanning, config.enable_home_dir_scanning, + tracing::Span::current(), ) { Ok(watcher) => { if let Ok(mut guard) = shared_picker.write() diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 4c33d553..918ca9d6 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -841,13 +841,17 @@ fn score_filtered_by_frecency<'a>( match files { FileItems::All(s) => s .par_iter() - .filter(|f| !f.is_deleted()) - .map(&score_file) + .filter_map(|f| { + let live = !f.is_deleted(); + live.then_some(score_file(f)) + }) .collect(), FileItems::Filtered(v) => v .iter() - .filter(|f| !f.is_deleted()) - .map(|&file| score_file(file)) + .filter_map(|f| { + let live = !f.is_deleted(); + live.then_some(score_file(f)) + }) .collect(), } } diff --git a/crates/fff-mcp/src/healthcheck.rs b/crates/fff-mcp/src/healthcheck.rs index 8a0acfe3..cc7db9e2 100644 --- a/crates/fff-mcp/src/healthcheck.rs +++ b/crates/fff-mcp/src/healthcheck.rs @@ -90,13 +90,13 @@ pub fn run_healthcheck(args: &Args) -> Result<(), Box> { check("History DB", false, "path not resolved"); } - // 5. Log file + // 5. Log path hint (per-session files written next to this path) if let Some(ref log_path) = args.log_file { let parent_ok = std::path::Path::new(log_path) .parent() - .is_some_and(|p| p.is_dir()); + .is_some_and(|p| p.is_dir() || p.parent().is_some()); all_ok &= check( - "Log file", + "Log path", parent_ok, if parent_ok { log_path @@ -105,7 +105,7 @@ pub fn run_healthcheck(args: &Args) -> Result<(), Box> { }, ); } else { - check("Log file", false, "path not resolved"); + check("Log path", false, "path not resolved"); } if all_ok { diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index 40c0b461..497c5ee9 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -1,11 +1,3 @@ -//! FFF MCP Server — high-performance file finder for AI code assistants. -//! -//! Drop-in replacement for AI code assistant file search tools (Glob/Grep). -//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and -//! code search via the Model Context Protocol (MCP). -//! -//! Uses `fff-core` directly (zero FFI overhead) for all search operations. - mod cursor; mod healthcheck; mod output; @@ -100,7 +92,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!( " !generated/ - exclude generated code", ); -/// FFF MCP Server — high-performance file finder for AI code assistants. +/// FFF MCP Server -- a high performance & accuracy file finder for AI code assistants. #[derive(Parser)] #[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))] pub(crate) struct Args { @@ -117,7 +109,8 @@ pub(crate) struct Args { #[allow(dead_code)] history_db_path: Option, - /// Path to the log file. + /// Path-shape hint for per-session log files. + /// Each fff-mcp startup writes a fresh sibling file `++.` #[arg(long = "log-file")] log_file: Option, @@ -201,7 +194,7 @@ async fn main() -> Result<(), Box> { } let log_file = args.log_file.as_deref().unwrap_or(""); - if let Err(e) = fff::log::init_tracing(log_file, args.log_level.as_deref()) { + if let Err(e) = fff::log::init_tracing(log_file, args.log_level.as_deref(), None) { eprintln!("Warning: Failed to init tracing: {}", e); } diff --git a/crates/fff-nvim/Cargo.toml b/crates/fff-nvim/Cargo.toml index 943e2f70..6241c1c3 100644 --- a/crates/fff-nvim/Cargo.toml +++ b/crates/fff-nvim/Cargo.toml @@ -11,50 +11,6 @@ crate-type = ["cdylib", "rlib"] default = [] zlob = ["fff/zlob"] -[[bin]] -name = "test_watcher" -path = "src/bin/test_watcher.rs" - -[[bin]] -name = "jemalloc_profile" -path = "src/bin/jemalloc_profile.rs" - -[[bin]] -name = "search_profiler" -path = "src/bin/search_profiler.rs" - -[[bin]] -name = "bench_search_only" -path = "src/bin/bench_search_only.rs" - -[[bin]] -name = "grep_profiler" -path = "src/bin/grep_profiler.rs" - -[[bin]] -name = "grep_vs_rg" -path = "src/bin/grep_vs_rg.rs" - -[[bin]] -name = "bench_grep_query" -path = "src/bin/bench_grep_query.rs" - -[[bin]] -name = "fuzzy_grep_test" -path = "src/bin/fuzzy_grep_test.rs" - -[[bin]] -name = "test_memory_leak" -path = "src/bin/test_memory_leak.rs" - -[[bin]] -name = "bench_ci_memmem" -path = "src/bin/bench_ci_memmem.rs" - -[[bin]] -name = "bench_lmdb_parallel" -path = "src/bin/bench_lmdb_parallel.rs" - [dependencies] # Workspace dependencies ahash = { workspace = true } diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 46f47691..5bfc94a1 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -57,9 +57,47 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult { Ok(QUERY_TRACKER.destroy().into_lua_result()?.is_some()) } +/// Opts table accepted by `init_file_picker` / `restart_index_in_path`. +/// Backwards compat: positional `follow_symlinks: bool` argument still works +/// — callers that pass a table use the named fields instead. +#[derive(Default)] +struct PickerInitOpts { + follow_symlinks: bool, + enable_fs_root_scanning: bool, + enable_home_dir_scanning: bool, +} + +impl PickerInitOpts { + fn from_lua_value(value: Option) -> LuaResult { + let Some(value) = value else { + return Ok(Self::default()); + }; + match value { + mlua::Value::Nil => Ok(Self::default()), + mlua::Value::Boolean(b) => Ok(Self { + follow_symlinks: b, + ..Default::default() + }), + mlua::Value::Table(t) => Ok(Self { + follow_symlinks: t.get::>("follow_symlinks")?.unwrap_or(false), + enable_fs_root_scanning: t + .get::>("enable_fs_root_scanning")? + .unwrap_or(false), + enable_home_dir_scanning: t + .get::>("enable_home_dir_scanning")? + .unwrap_or(false), + }), + other => Err(LuaError::RuntimeError(format!( + "init opts must be a table, boolean, or nil — got {}", + other.type_name() + ))), + } + } +} + pub fn init_file_picker( _: &Lua, - (base_path, follow_symlinks): (String, Option), + (base_path, opts): (String, Option), ) -> LuaResult { { let guard = FILE_PICKER.read().into_lua_result()?; @@ -68,6 +106,8 @@ pub fn init_file_picker( } } + let opts = PickerInitOpts::from_lua_value(opts)?; + FilePicker::new_with_shared_state( FILE_PICKER.clone(), FRECENCY.clone(), @@ -76,7 +116,9 @@ pub fn init_file_picker( enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, - follow_symlinks: follow_symlinks.unwrap_or(false), + follow_symlinks: opts.follow_symlinks, + enable_fs_root_scanning: opts.enable_fs_root_scanning, + enable_home_dir_scanning: opts.enable_home_dir_scanning, ..Default::default() }, ) @@ -85,7 +127,10 @@ pub fn init_file_picker( Ok(true) } -pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { +pub fn restart_index_in_path( + _: &Lua, + (new_path, opts): (String, Option), +) -> LuaResult<()> { let path = std::path::PathBuf::from(&new_path); if !path.exists() { return Err(LuaError::RuntimeError(format!( @@ -98,6 +143,8 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e)) })?; + let opts = PickerInitOpts::from_lua_value(opts)?; + // Spawn a background thread BEFORE touching the picker lock. The // same-dir short-circuit previously called `FILE_PICKER.read()` on // the lua/UI thread, which blocks if a reindex writer is already in @@ -112,7 +159,11 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { ?canonical_path, "restart_index_in_path: spawned worker running" ); - { + + // Inherit current picker's scanning flags when caller didn't pass + // explicit opts — otherwise a `:cd ~` after init would silently lose + // the user's `enable_home_dir_scanning = true` setting. + let (follow_symlinks, fs_root, home_dir) = { let guard = match FILE_PICKER.read() { Ok(g) => g, Err(_) => return, @@ -124,7 +175,20 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { tracing::info!(?canonical_path, "restart_index_in_path: same dir, skipping"); return; } - } + + match guard.as_ref() { + Some(p) => ( + p.follows_symlinks() || opts.follow_symlinks, + p.fs_root_scanning_enabled() || opts.enable_fs_root_scanning, + p.home_dir_scanning_enabled() || opts.enable_home_dir_scanning, + ), + None => ( + opts.follow_symlinks, + opts.enable_fs_root_scanning, + opts.enable_home_dir_scanning, + ), + } + }; ::tracing::info!( ?canonical_path, @@ -141,6 +205,9 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> { enable_mmap_cache: true, enable_content_indexing: true, mode: FFFMode::Neovim, + follow_symlinks, + enable_fs_root_scanning: fs_root, + enable_home_dir_scanning: home_dir, ..Default::default() }, ) { @@ -724,9 +791,9 @@ pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option) -> LuaResult), + (log_file_path, log_level, retain_runs): (String, Option, Option), ) -> LuaResult { - crate::log::init_tracing(&log_file_path, log_level.as_deref()) + crate::log::init_tracing(&log_file_path, log_level.as_deref(), retain_runs) .map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e))) } @@ -948,8 +1015,9 @@ fn create_exports(lua: &Lua) -> LuaResult { // https://github.com/mlua-rs/mlua/issues/318 #[mlua::lua_module(skip_memory_check)] fn fff_nvim(lua: &Lua) -> LuaResult { - // Install panic hook IMMEDIATELY on module load - // This ensures any panics are logged even if init_tracing is never called + // Install panic hook + SIGSEGV chain handler IMMEDIATELY on module load. + // Without this, a crash inside fff produces a silent nvim death — neovim's + // own handler does not log a Rust-side backtrace. crate::log::install_panic_hook(); create_exports(lua) diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 5538e777..6edaa297 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -68,6 +68,8 @@ local M = {} --- @field lazy_sync boolean --- @field prompt_vim_mode boolean --- @field follow_symlinks boolean +--- @field enable_fs_root_scanning boolean +--- @field enable_home_dir_scanning boolean --- @field layout FffLayoutConfig --- @field preview FffPreviewConfig --- @field keymaps FffKeymapsConfig @@ -205,6 +207,10 @@ local function init() prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: leaves insert for normal mode bindings (also allows p or l to jump around) the second closes the picker wrap_around = false, -- set to true to wrap cursor to the opposite end when reaching the first/last item follow_symlinks = false, -- set to true to follow symbolic links during file indexing + -- Allow fff in the user's $HOME director. + enable_home_dir_scanning = true, + -- Allow fff in a filesystem root (e.g. `/`, `C:\`) + enable_fs_root_scanning = false, layout = { height = 0.8, width = 0.8, @@ -362,8 +368,15 @@ local function init() }, logging = { enabled = true, + -- Path-shape hint: each nvim startup writes a fresh sibling file + -- `++.` next to this path. The literal + -- path itself is never written to — multiple concurrent nvim instances + -- get their own per-pid file with no locking. log_file = vim.fn.stdpath('log') .. '/fff.log', log_level = 'info', + -- How many session log files to retain. Newest are kept, older are + -- pruned on the next startup. Set to 0 to disable retention. + retain_runs = 20, }, -- find_files settings file_picker = { diff --git a/lua/fff/core.lua b/lua/fff/core.lua index e4e28496..cacdf415 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -110,7 +110,12 @@ M.change_indexing_directory = function(new_path) end local fff_rust = M.ensure_initialized() - local ok, err = pcall(fff_rust.restart_index_in_path, expanded_path) + local config = require('fff.conf').get() + local ok, err = pcall(fff_rust.restart_index_in_path, expanded_path, { + follow_symlinks = config.follow_symlinks, + enable_fs_root_scanning = config.enable_fs_root_scanning, + enable_home_dir_scanning = config.enable_home_dir_scanning, + }) if not ok then vim.notify('Failed to change directory: ' .. err, vim.log.levels.ERROR) return false @@ -126,7 +131,8 @@ M.ensure_initialized = function() local config = require('fff.conf').get() if config.logging.enabled then - local log_success, log_error = pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level) + local log_success, log_error = + pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level, config.logging.retain_runs) if log_success then M.log_file_path = log_error else @@ -140,7 +146,11 @@ M.ensure_initialized = function() local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true) if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end - ok, result = pcall(fuzzy.init_file_picker, config.base_path, config.follow_symlinks) + ok, result = pcall(fuzzy.init_file_picker, config.base_path, { + follow_symlinks = config.follow_symlinks, + enable_fs_root_scanning = config.enable_fs_root_scanning, + enable_home_dir_scanning = config.enable_home_dir_scanning, + }) if not ok then vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR) return fuzzy diff --git a/plugin/fff.lua b/plugin/fff.lua index 053017ca..fb14fcce 100644 --- a/plugin/fff.lua +++ b/plugin/fff.lua @@ -92,13 +92,11 @@ end, { }) vim.api.nvim_create_user_command('FFFOpenLog', function() - local fff = require('fff') - local config = require('fff.conf').get() - if fff.log_file_path then - vim.cmd('tabnew ' .. vim.fn.fnameescape(fff.log_file_path)) - elseif config and config.logging and config.logging.log_file then - -- Fallback to the configured log file path even if tracing wasn't initialized - vim.cmd('tabnew ' .. vim.fn.fnameescape(config.logging.log_file)) + local core = require('fff.core') + core.ensure_initialized() + + if core.log_file_path then + vim.cmd('tabnew ' .. vim.fn.fnameescape(core.log_file_path)) else vim.notify('Log file path not available', vim.log.levels.ERROR) end