Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,32 @@
[package]
license = "Apache-2.0"
name = "xai-fsnotify"
version = "0.1.0"
edition.workspace = true
description = "Local-filesystem event source: single causal stream of semantic FsEvents"
[dependencies]
dunce = { workspace = true }
notify = "8"
notify-debouncer-full = "0.5"
ignore = { workspace = true }
globset = "0.4"
tokio = { workspace = true, features = ["sync", "time", "rt"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true, features = ["derive"] }
git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
serial_test = "3"
criterion = { workspace = true }
xai-tracing-macros = { path = "../xai-tracing-macros" }
[[bench]]
name = "startup"
harness = false
[lints]
workspace = true

View file

@ -0,0 +1,147 @@
//! Watcher startup-latency benchmark.
//!
//! All scenarios build ~12k total dirs so inotify-watch creation cost is
//! comparable across them:
//!
//! - `favorable` — most dirs live in a gitignored `target/` the new code skips.
//! - `fanout_w48_with_target` — 48 non-ignored top-level children PLUS a
//! gitignored `target/`: a realistic moderate-width repo that fans out and
//! skips the build dir (net win).
//! - `fanout_w64_no_ignored` — 64 non-ignored children, nothing ignored: the
//! fan-out path's worst case (pure per-child `watch()` round-trip overhead,
//! nothing to skip), bounding the cost at the threshold.
//! - `wide_w400` — 400 non-ignored children: above the threshold, so it
//! exercises the recursive-root fallback (recursive-vs-recursive).
//! - `nested_ignored_js_shape` — node_modules-heavy tree where ~5/6 of the
//! dirs are gitignored *below* the top level: per-dir mode (Linux default)
//! prunes them; fan-out mode pays for them on emulated-recursion backends.
//!
//! Run with `cargo bench -p xai-fsnotify --bench startup`. Medians land in
//! `target/criterion/watcher_startup/<scenario>/new/estimates.json`.
//! `GROK_FSNOTIFY_PER_DIR=0|1` pins the strategy for A/B runs.
use std::fs;
use std::path::Path;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use tempfile::TempDir;
use xai_fsnotify::{FsConfig, FsEventSource};
const TOTAL_DIRS: usize = 12_000;
/// Create `count` nested directories under `base`, grouped 100 per parent so
/// the tree has realistic fan-out and depth rather than one flat directory.
fn make_dirs(base: &Path, count: usize) {
for i in 0..count {
let dir = base.join(format!("g{}", i / 100)).join(format!("d{i}"));
fs::create_dir_all(&dir).unwrap();
}
}
/// Favorable: three watched subtrees plus a large gitignored `target/` holding
/// ~2/3 of the dirs. Kept at ~`TOTAL_DIRS` for comparability with the others.
fn build_favorable_tree() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".gitignore"), "target/\n").unwrap();
let target = TOTAL_DIRS * 2 / 3;
let per = (TOTAL_DIRS - target) / 3;
make_dirs(&root.join("src"), per);
make_dirs(&root.join("crates"), per);
make_dirs(&root.join("tests"), per);
make_dirs(&root.join("target"), target);
temp
}
/// `width` non-ignored top-level children. When `with_target`, half the dirs
/// live in a gitignored `target/` (skipped by fan-out); otherwise nothing is
/// ignored.
fn build_wide_tree(width: usize, with_target: bool) -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".git")).unwrap();
let child_total = if with_target {
TOTAL_DIRS / 2
} else {
TOTAL_DIRS
};
let per_child = child_total / width;
for i in 0..width {
make_dirs(&root.join(format!("pkg{i}")), per_child);
}
if with_target {
fs::write(root.join(".gitignore"), "target/\n").unwrap();
make_dirs(&root.join("target"), TOTAL_DIRS / 2);
}
temp
}
/// JS-monorepo shape: ~1/6 of the dirs are sources across a few packages; the
/// rest live in `node_modules/` trees nested *below* the top level, which the
/// fan-out strategy's recursive child watches cannot skip (on inotify each of
/// those dirs still costs a watch descriptor) but per-dir mode prunes.
fn build_nested_ignored_tree() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
let src = TOTAL_DIRS / 6;
let ignored = TOTAL_DIRS - 2 * src;
for i in 0..4 {
make_dirs(&root.join(format!("packages/pkg{i}/src")), src / 4);
make_dirs(
&root.join(format!("packages/pkg{i}/node_modules")),
ignored / 8,
);
}
make_dirs(&root.join("node_modules"), ignored / 2);
make_dirs(&root.join("apps/web/src"), src);
temp
}
fn bench_startup(c: &mut Criterion) {
// `start` blocks on a std mpsc ready signal; the tokio loop is only spawned,
// never awaited during timing, so a current-thread runtime suffices.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _guard = rt.enter();
let mut group = c.benchmark_group("watcher_startup");
group.sample_size(30);
let mut run = |name: &str, tree: &TempDir| {
let root = tree.path().to_path_buf();
group.bench_function(name, |b| {
// PerIteration drops each watcher (freeing its inotify watches)
// before the next iteration and keeps the drop out of timing.
b.iter_batched(
|| (),
|()| FsEventSource::start(root.clone(), FsConfig::default()).expect("start"),
BatchSize::PerIteration,
);
});
};
let favorable = build_favorable_tree();
run("favorable", &favorable);
let moderate = build_wide_tree(48, true);
run("fanout_w48_with_target", &moderate);
let worst = build_wide_tree(64, false);
run("fanout_w64_no_ignored", &worst);
let wide = build_wide_tree(400, false);
run("wide_w400", &wide);
let nested = build_nested_ignored_tree();
run("nested_ignored_js_shape", &nested);
group.finish();
}
criterion_group!(benches, bench_startup);
criterion_main!(benches);

View file

@ -0,0 +1,211 @@
//! Watch-footprint benchmark harness.
//!
//! Measures what a live `FsEventSource` costs the OS: watch count (crate
//! accounting + `/proc/self/fdinfo` inotify ground truth on Linux) and
//! startup latency, under either strategy (`GROK_FSNOTIFY_PER_DIR=0|1`).
//!
//! ```bash
//! # Generate a synthetic tree, then measure both strategies against it:
//! cargo run --release -p xai-fsnotify --example watch_stats -- gen js /tmp/js-repo
//! GROK_FSNOTIFY_PER_DIR=0 cargo run --release -p xai-fsnotify --example watch_stats -- run /tmp/js-repo 5
//! GROK_FSNOTIFY_PER_DIR=1 cargo run --release -p xai-fsnotify --example watch_stats -- run /tmp/js-repo 5
//! ```
//!
//! Tree shapes are scaled replicas of synthetic large-repo measurements:
//! - `js`: a JS/turbo monorepo where `node_modules/` trees nested below the
//! top level dominate the directory count (the shape behind the original
//! "grok holds 55k inotify watches" report).
//! - `large`: a wide multi-language monorepo — 44 top-level dirs, ~52k
//! non-ignored dirs, ~7k nested-ignored, a large top-level `target/`, and
//! a `.git` with 13k+ internal dirs (objects/modules/logs/refs-remotes).
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use xai_fsnotify::{FsConfig, FsEventSource};
fn make_dirs(base: &Path, count: usize, fanout: usize) {
for i in 0..count {
let dir = base.join(format!("g{}", i / fanout)).join(format!("d{i}"));
fs::create_dir_all(&dir).unwrap();
}
}
fn make_git_dir(root: &Path, objects: usize, logs: usize, remotes: usize, modules: usize) {
let gd = root.join(".git");
fs::create_dir_all(gd.join("refs/heads")).unwrap();
fs::create_dir_all(gd.join("refs/tags")).unwrap();
fs::write(gd.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(gd.join("index"), "").unwrap();
for i in 0..objects {
fs::create_dir_all(gd.join(format!("objects/{i:02x}"))).unwrap();
}
for i in 0..remotes {
fs::create_dir_all(gd.join(format!("refs/remotes/origin/user{}/f{i}", i / 40))).unwrap();
}
for i in 0..logs {
fs::create_dir_all(gd.join(format!("logs/refs/remotes/origin/user{}/f{i}", i / 40)))
.unwrap();
}
for i in 0..modules {
fs::create_dir_all(gd.join(format!("modules/sub{}/objects/{:02x}", i / 30, i % 256)))
.unwrap();
}
}
/// JS monorepo: 3 apps + 12 packages of sources (~3.2k non-ignored dirs) and
/// nested `node_modules/` holding ~29k dirs — ignored, below the top level.
fn gen_js(root: &Path) {
fs::write(root.join(".gitignore"), "node_modules/\ndist/\n").unwrap();
make_git_dir(root, 256, 400, 120, 0);
for app in 0..3 {
let a = root.join(format!("apps/app{app}"));
make_dirs(&a.join("src"), 150, 12);
make_dirs(&a.join("node_modules"), 4500, 15);
make_dirs(&a.join("dist"), 300, 20);
}
for pkg in 0..12 {
let p = root.join(format!("packages/pkg{pkg}"));
make_dirs(&p.join("src"), 120, 10);
make_dirs(&p.join("node_modules"), 1200, 15);
}
// Root node_modules: the hoisted bulk.
make_dirs(&root.join("node_modules"), 12000, 15);
fs::write(root.join("package.json"), "{}").unwrap();
}
/// Wide multi-language monorepo shape (scaled synthetic distribution).
fn gen_large(root: &Path) {
fs::write(root.join(".gitignore"), "target/\nnode_modules/\n.venv/\n").unwrap();
make_git_dir(root, 256, 9000, 2500, 800);
// 44 top-level dirs; weights exercise a realistic wide fan-out.
let weights: &[(&str, usize)] = &[
("apps", 23_000),
("services", 6_600),
("crates", 5_100),
("frontend", 4_600),
("python", 3_400),
("tools", 1_800),
("libs", 1_800),
("infra", 1_500),
];
for (name, dirs) in weights {
make_dirs(&root.join(name), *dirs, 40);
}
for i in 0..36 {
make_dirs(&root.join(format!("misc{i}")), 100, 20);
}
// Nested ignored trees (~7.4k dirs): per-language build/dep dirs.
make_dirs(&root.join("frontend/node_modules"), 4_000, 15);
make_dirs(&root.join("python/common/.venv"), 2_000, 20);
make_dirs(&root.join("crates/foo/target"), 1_400, 30);
// Top-level ignored target/ (~34k dirs): fan-out already skips it; the
// recursive-root fallback (>64 top-level dirs) would not.
make_dirs(&root.join("target"), 34_000, 50);
}
/// Ground truth: total inotify watches held by this process (Linux).
fn inotify_watches() -> usize {
#[cfg(target_os = "linux")]
{
let Ok(fds) = fs::read_dir("/proc/self/fdinfo") else {
return 0;
};
fds.flatten()
.filter_map(|e| fs::read_to_string(e.path()).ok())
.map(|s| s.lines().filter(|l| l.starts_with("inotify wd:")).count())
.sum()
}
#[cfg(not(target_os = "linux"))]
{
0
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("gen") => {
let kind = args.get(2).expect("gen <js|large> <path>");
let path = PathBuf::from(args.get(3).expect("gen <js|large> <path>"));
fs::create_dir_all(&path).unwrap();
let t = Instant::now();
match kind.as_str() {
"js" => gen_js(&path),
"large" => gen_large(&path),
other => panic!("unknown tree kind: {other}"),
}
let total = walkdir_count(&path);
println!(
"generated {kind} tree at {} ({total} dirs) in {:?}",
path.display(),
t.elapsed()
);
}
Some("run") => {
let path = PathBuf::from(args.get(2).expect("run <path> [iters]"));
let iters: usize = args.get(3).map_or(3, |s| s.parse().unwrap());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _guard = rt.enter();
let strategy = std::env::var("GROK_FSNOTIFY_PER_DIR").unwrap_or_default();
let mut ready_ms = Vec::new();
let mut armed_ms = Vec::new();
let mut counts = (0usize, 0usize);
for _ in 0..iters {
let t = Instant::now();
let source =
FsEventSource::start(path.clone(), FsConfig::default()).expect("watcher start");
ready_ms.push(t.elapsed().as_secs_f64() * 1e3);
// Steady state: background arming (per-dir mode on big trees)
// is done once the kernel watch count stops moving.
let mut last = inotify_watches();
let deadline = Instant::now() + std::time::Duration::from_secs(120);
loop {
std::thread::sleep(std::time::Duration::from_millis(250));
let n = inotify_watches();
if n == last || Instant::now() > deadline {
break;
}
last = n;
}
armed_ms.push(t.elapsed().as_secs_f64() * 1e3);
counts = (source.os_watch_count(), inotify_watches());
drop(source);
// Let the watcher thread release its watches before re-measuring.
std::thread::sleep(std::time::Duration::from_millis(200));
}
ready_ms.sort_by(|a, b| a.total_cmp(b));
armed_ms.sort_by(|a, b| a.total_cmp(b));
println!(
"per_dir_env={strategy:?} tree={} watches_crate={} watches_kernel={} ready_ms_median={:.0} fully_armed_ms_median~{:.0} (n={iters})",
path.display(),
counts.0,
counts.1,
ready_ms[ready_ms.len() / 2],
armed_ms[armed_ms.len() / 2],
);
}
_ => eprintln!("usage: watch_stats gen <js|large> <path> | run <path> [iters]"),
}
}
fn walkdir_count(root: &Path) -> usize {
let mut n = 0;
let mut stack = vec![root.to_path_buf()];
while let Some(d) = stack.pop() {
if let Ok(rd) = fs::read_dir(&d) {
for e in rd.flatten() {
if e.file_type().is_ok_and(|t| t.is_dir()) {
n += 1;
stack.push(e.path());
}
}
}
}
n
}

View file

@ -0,0 +1,14 @@
/// Terminal in-process error from [`crate::FsEventSource::start`]. Not
/// `Serialize`: never crosses the workspace transport boundary.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FsNotifyError {
#[error("failed to start watcher")]
WatcherStart(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("watcher initialization timed out")]
Timeout,
#[error("FsEventSource::start called outside a tokio runtime")]
NoRuntime,
}

View file

@ -0,0 +1,76 @@
//! Public event types — the wire contract for `xai-fsnotify`.
//!
//! Pure data: no I/O, no tokio, no intra-crate deps. Safe to lift into a
//! sibling `-types` crate for WASM/no-tokio consumers.
//!
//! All variants are `#[non_exhaustive]`; add additively. The workspace
//! translator (in `xai-grok-workspace`) maps these to
//! `WorkspaceEvent`s and enriches `GitOperationCompleted { head_changed:
//! true }` with `commit + branch + vcs` via a git shell-out — that I/O
//! belongs at the workspace layer, not on the OS-watcher hot path.
use std::path::PathBuf;
/// One semantic event from the local workspace. Causal order on the
/// source's broadcast channel. `FilesChanged` paths share a single `kind`
/// (per-debounce-window grouping); per-event causality would need
/// `Vec<{path, kind}>`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
#[non_exhaustive]
pub enum FsEvent {
/// Workspace file changes; all paths share `kind`. Paths under
/// `git_dir` are excluded (metadata surfaces as `GitMetaChanged`,
/// `.lock` files are dropped).
FilesChanged {
paths: Vec<PathBuf>,
kind: FsEventKind,
},
/// A git metadata file changed (HEAD, index, refs/, FETCH_HEAD).
GitMetaChanged { kind: GitMetaKind },
/// VCS lock activity observed: `index.lock`/`gc.pid`/`.sl` `wlock` is
/// present, or an event for one arrived with the file already gone (fast
/// ops complete inside one debounce batch). State is in flux until the
/// matching `GitOperationCompleted` arrives.
GitOperationStarted,
/// The lock has been gone for [`crate::SETTLE_MS`]: rapid lock cycles
/// (rebase/squash picks) merge into one operation, so one pair is emitted
/// per burst, not per cycle. `head_changed` reports whether `.git/HEAD`
/// differs from its value when the operation's *first* lock appeared.
GitOperationCompleted { head_changed: bool },
}
/// Aligned with `xai_grok_workspace_types::FsEventKind` (identity map at
/// the workspace boundary). `notify::EventKind::{Access, Any, Other}` are
/// filtered upstream and never surface here.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FsEventKind {
Created,
#[default]
Modified,
Removed,
Renamed,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum GitMetaKind {
/// `.git/HEAD` (branch switch, commit, rebase step).
HeadChanged,
/// `.git/index` (`git add`, `git reset`, `git commit`).
IndexChanged,
/// `.git/refs/*` or `.git/packed-refs` (ref updates).
RefsChanged,
/// `.git/FETCH_HEAD` (fetch / pull).
FetchHeadChanged,
}

View file

@ -0,0 +1,20 @@
//! Local-filesystem event source. Single causal stream of wire-ready
//! [`FsEvent`]s on one broadcast channel. The `xai-grok-workspace` layer
//! translates these into `WorkspaceEvent`s with git-enrichment I/O.
//!
//! Single workspace root only; multi-root composition (parent + worktrees)
//! lives in the workspace layer.
mod error;
mod event;
mod paths;
mod source;
mod state;
mod watcher;
pub use error::FsNotifyError;
pub use event::{FsEvent, FsEventKind, GitMetaKind};
pub use source::{
FsConfig, FsEventSource, FsWatcherStats, STATS_TARGET, set_runtime_handle, shared, stats,
};
pub use state::SETTLE_MS;

View file

@ -0,0 +1,85 @@
//! `.git/` path classification. Component-based against the discovered
//! `git_dir` (not substring matching), so `/tmp/.git-backup/HEAD` is safe
//! and Windows separators work.
//!
//! Watched: `HEAD`, `index`, `refs/*`, `packed-refs`, `FETCH_HEAD`.
//! Skipped: `COMMIT_EDITMSG`, `MERGE_HEAD`, `REBASE_HEAD`, `objects/*`
//! (too noisy or no meaningful state change). `index.lock` is handled by
//! the lock state machine, not here.
use std::path::Path;
use crate::event::GitMetaKind;
/// `git_dir` is from `git2::Repository::discover().path()` (handles worktrees).
pub(crate) fn classify_git_path(path: &Path, git_dir: &Path) -> Option<GitMetaKind> {
let rel = path.strip_prefix(git_dir).ok()?.to_str()?;
match rel {
"HEAD" => Some(GitMetaKind::HeadChanged),
"FETCH_HEAD" => Some(GitMetaKind::FetchHeadChanged),
"index" => Some(GitMetaKind::IndexChanged),
"packed-refs" => Some(GitMetaKind::RefsChanged),
s if s.starts_with("refs/") || s.starts_with("refs\\") => Some(GitMetaKind::RefsChanged),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn classify(p: &str, git_dir: &str) -> Option<GitMetaKind> {
classify_git_path(&PathBuf::from(p), &PathBuf::from(git_dir))
}
#[test]
fn classify_positive_cases() {
let g = "/r/.git";
assert_eq!(classify("/r/.git/HEAD", g), Some(GitMetaKind::HeadChanged));
assert_eq!(
classify("/r/.git/index", g),
Some(GitMetaKind::IndexChanged)
);
assert_eq!(
classify("/r/.git/FETCH_HEAD", g),
Some(GitMetaKind::FetchHeadChanged)
);
assert_eq!(
classify("/r/.git/packed-refs", g),
Some(GitMetaKind::RefsChanged)
);
assert_eq!(
classify("/r/.git/refs/heads/feature-branch-with-slashes", g),
Some(GitMetaKind::RefsChanged)
);
assert_eq!(
classify("/r/.git/refs/remotes/origin/main", g),
Some(GitMetaKind::RefsChanged)
);
}
#[test]
fn classify_returns_none() {
let g = "/r/.git";
// Excluded git internals.
assert_eq!(classify("/r/.git/COMMIT_EDITMSG", g), None);
assert_eq!(classify("/r/.git/MERGE_HEAD", g), None);
assert_eq!(classify("/r/.git/objects/ab/1234", g), None);
assert_eq!(classify("/r/.git/index.lock", g), None);
// Workspace files.
assert_eq!(classify("/r/src/main.rs", g), None);
// Substring false-positive prevented by strip_prefix.
assert_eq!(classify("/r/.git-backup/HEAD", g), None);
// Path under a different git_dir.
assert_eq!(classify("/other/.git/HEAD", g), None);
}
#[test]
fn classify_handles_worktree_gitdir() {
assert_eq!(
classify("/r/.git/worktrees/wt/HEAD", "/r/.git/worktrees/wt"),
Some(GitMetaKind::HeadChanged)
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,373 @@
//! Lock-state machine. Pure data + pure transition function. No I/O.
use std::time::{Duration, Instant};
/// Drop transient OS events for this window after a head-changing op;
/// consumers refresh from scratch anyway.
pub(crate) const COOLDOWN_MS: u64 = 500;
/// After a lock release, wait this long before declaring the operation
/// complete: a lock reappearing within the window (a rebase/squash cycles
/// `index.lock` per pick) is the *same* operation, so rapid cycles merge into
/// one `Started`/`Completed` pair instead of storming consumers.
pub const SETTLE_MS: u64 = 500;
/// Diagnostic threshold — fires a one-time warning when a lock is held
/// longer than this. `git gc` on huge repos can exceed this legitimately;
/// the state machine stays locked until the lock file disappears regardless.
const STALE_LOCK_SECS: u64 = 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LockState {
Idle,
Locked {
head_at_start: Option<String>,
since: Instant,
},
/// Lock released, operation not yet declared complete. `head_at_start`
/// and `since` are carried from the first `Locked` entry of the merged
/// operation so re-locks preserve the op-wide HEAD comparison and the
/// stale-lock clock.
Settling {
head_at_start: Option<String>,
since: Instant,
until: Instant,
},
Cooldown {
until: Instant,
},
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LockTransition {
None,
Started,
/// Emitted on any `Locked → !Locked` transition. `head_changed` is the
/// HEAD comparison; cooldown begins iff true.
Completed {
head_changed: bool,
},
/// Cooldown timer expired; consumer never sees this — internal only.
CooldownEnded,
}
/// One step. Pure; mutates `state` from freshly-observed FS facts.
pub(crate) fn drive(
state: &mut LockState,
lock_present: bool,
head_now: Option<String>,
now: Instant,
cooldown: Duration,
) -> LockTransition {
match (state.clone(), lock_present) {
(LockState::Idle, true) | (LockState::Cooldown { .. }, true) => {
*state = LockState::Locked {
head_at_start: head_now,
since: now,
};
LockTransition::Started
}
// Same operation resumes: keep the op-start HEAD and `since` so the
// eventual Completed spans the whole merged op. No duplicate Started —
// consumers never saw a Completed, so their in-op flag never flipped.
(
LockState::Settling {
head_at_start,
since,
..
},
true,
) => {
*state = LockState::Locked {
head_at_start,
since,
};
LockTransition::None
}
// Don't complete yet: give a rapid re-lock the settle window to merge.
(
LockState::Locked {
head_at_start,
since,
},
false,
) => {
*state = LockState::Settling {
head_at_start,
since,
until: now + Duration::from_millis(SETTLE_MS),
};
LockTransition::None
}
(
LockState::Settling {
head_at_start,
until,
..
},
false,
) if now >= until => {
let head_changed = head_at_start.as_ref() != head_now.as_ref();
*state = if head_changed {
LockState::Cooldown {
until: now + cooldown,
}
} else {
LockState::Idle
};
LockTransition::Completed { head_changed }
}
(LockState::Cooldown { until }, false) if now >= until => {
*state = LockState::Idle;
LockTransition::CooldownEnded
}
_ => LockTransition::None,
}
}
/// `check` fires once per stale period; resets when the lock releases.
#[derive(Debug, Default)]
pub(crate) struct StaleWarn {
warned: bool,
}
impl StaleWarn {
pub(crate) fn check(&mut self, state: &LockState, now: Instant) -> Option<Duration> {
match state {
// Settling counts as held: `since` spans the merged operation, so
// a long rebase of short lock cycles still warns (once), and the
// latch doesn't reset in the sub-second gaps between cycles.
LockState::Locked { since, .. } | LockState::Settling { since, .. } => {
let elapsed = now.duration_since(*since);
if !self.warned && elapsed > Duration::from_secs(STALE_LOCK_SECS) {
self.warned = true;
return Some(elapsed);
}
None
}
_ => {
self.warned = false;
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cooldown() -> Duration {
Duration::from_millis(500)
}
#[test]
fn idle_to_locked_on_lock_appearance() {
let mut s = LockState::Idle;
let now = Instant::now();
assert_eq!(
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
LockTransition::Started
);
assert!(matches!(s, LockState::Locked { .. }));
}
/// A lock release no longer completes the operation; it opens the settle
/// window (rapid re-locks merge) and emits nothing.
#[test]
fn locked_to_settling_emits_nothing() {
let now = Instant::now();
let mut s = LockState::Locked {
head_at_start: Some("ref: main".into()),
since: now,
};
assert_eq!(
drive(&mut s, false, Some("ref: feature".into()), now, cooldown()),
LockTransition::None
);
match &s {
LockState::Settling {
head_at_start,
since,
until,
} => {
assert_eq!(head_at_start.as_deref(), Some("ref: main"));
assert_eq!(*since, now);
assert_eq!(*until, now + Duration::from_millis(SETTLE_MS));
}
other => panic!("expected Settling, got {other:?}"),
}
}
/// Re-lock inside the settle window: the same operation continues, so the
/// op-start HEAD and `since` are preserved and nothing is emitted (no
/// duplicate Started — the consumer's in-op flag never flipped).
#[test]
fn settling_relock_preserves_op_start_and_emits_nothing() {
let op_start = Instant::now();
let later = op_start + Duration::from_millis(100);
let mut s = LockState::Settling {
head_at_start: Some("ref: main".into()),
since: op_start,
until: later + Duration::from_millis(400),
};
assert_eq!(
drive(&mut s, true, Some("pick-1".into()), later, cooldown()),
LockTransition::None
);
assert_eq!(
s,
LockState::Locked {
head_at_start: Some("ref: main".into()),
since: op_start,
},
"op-start HEAD and since must survive the re-lock"
);
}
/// Settle expiry emits exactly one Completed comparing the first pick's
/// pre-op HEAD against the final HEAD (head_changed spans the merged op).
#[test]
fn settling_expiry_emits_completed_spanning_merged_op() {
let now = Instant::now();
let mut s = LockState::Settling {
head_at_start: Some("ref: main".into()),
since: now - Duration::from_secs(1),
until: now,
};
assert_eq!(
drive(&mut s, false, Some("pick-4".into()), now, cooldown()),
LockTransition::Completed { head_changed: true }
);
assert!(matches!(s, LockState::Cooldown { .. }));
}
#[test]
fn settling_expiry_head_unchanged_goes_idle() {
let now = Instant::now();
let mut s = LockState::Settling {
head_at_start: Some("ref: main".into()),
since: now - Duration::from_secs(1),
until: now,
};
assert_eq!(
drive(&mut s, false, Some("ref: main".into()), now, cooldown()),
LockTransition::Completed {
head_changed: false
}
);
assert_eq!(s, LockState::Idle);
}
#[test]
fn settling_before_expiry_emits_nothing() {
let now = Instant::now();
let mut s = LockState::Settling {
head_at_start: Some("ref: main".into()),
since: now,
until: now + Duration::from_millis(1),
};
assert_eq!(
drive(&mut s, false, Some("pick-1".into()), now, cooldown()),
LockTransition::None
);
assert!(matches!(s, LockState::Settling { .. }));
}
#[test]
fn cooldown_to_idle_after_timer() {
let start = Instant::now();
let mut s = LockState::Cooldown { until: start };
let later = start + Duration::from_millis(1);
assert_eq!(
drive(&mut s, false, None, later, cooldown()),
LockTransition::CooldownEnded
);
assert_eq!(s, LockState::Idle);
}
#[test]
fn cooldown_to_locked_on_re_acquire() {
let now = Instant::now();
let mut s = LockState::Cooldown {
until: now + Duration::from_millis(500),
};
assert_eq!(
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
LockTransition::Started
);
assert!(matches!(s, LockState::Locked { .. }));
}
/// Regression: timer-arm `drive()` must report Started so the consumer's
/// `in_op` flag flips; otherwise FilesChanged events skip buffering.
#[test]
fn cooldown_to_locked_when_lock_reappears_at_timer_fire() {
let now = Instant::now();
let mut s = LockState::Cooldown { until: now };
assert_eq!(
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
LockTransition::Started,
);
assert!(matches!(s, LockState::Locked { .. }));
}
#[test]
fn no_transition_when_idle_and_no_lock() {
let mut s = LockState::Idle;
assert_eq!(
drive(&mut s, false, None, Instant::now(), cooldown()),
LockTransition::None
);
assert_eq!(s, LockState::Idle);
}
#[test]
fn stale_warn_fires_once_per_stale_period() {
let now = Instant::now();
let s = LockState::Locked {
head_at_start: None,
since: now - Duration::from_secs(STALE_LOCK_SECS + 1),
};
let mut w = StaleWarn::default();
assert!(w.check(&s, now).is_some());
// Second check while still Locked: latched, no re-fire.
assert!(w.check(&s, now).is_none());
}
#[test]
fn stale_warn_resets_when_lock_releases() {
let now = Instant::now();
let locked = LockState::Locked {
head_at_start: None,
since: now - Duration::from_secs(STALE_LOCK_SECS + 1),
};
let mut w = StaleWarn::default();
assert!(w.check(&locked, now).is_some());
assert!(w.check(&LockState::Idle, now).is_none());
// Re-acquire: should fire again.
assert!(w.check(&locked, now).is_some());
}
/// A long rebase made of short lock cycles: `since` spans the merged op,
/// so the warning fires once past the threshold and the settle gaps
/// between cycles neither reset the latch nor re-fire it.
#[test]
fn stale_warn_spans_merged_op_and_stays_latched_through_settling() {
let now = Instant::now();
let op_start = now - Duration::from_secs(STALE_LOCK_SECS + 1);
let settling = LockState::Settling {
head_at_start: None,
since: op_start,
until: now + Duration::from_millis(SETTLE_MS),
};
let locked = LockState::Locked {
head_at_start: None,
since: op_start,
};
let mut w = StaleWarn::default();
assert!(w.check(&settling, now).is_some(), "settling counts as held");
assert!(w.check(&locked, now).is_none(), "latched across re-lock");
assert!(w.check(&settling, now).is_none(), "latched across release");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,370 @@
//! Integration tests using the public API only. Each test exercises the
//! real OS watcher against a `tempfile`-rooted fake git repo.
//!
//! These can be flaky on some CI runners where FS events aren't reliably
//! delivered (matches the existing pattern in `watcher.rs` integration
//! tests). Marked `#[ignore]` for now; run locally with
//! `cargo test --test integration -- --ignored`.
use std::fs;
use std::time::Duration;
use serial_test::serial;
use tempfile::TempDir;
use tokio::sync::broadcast;
use tokio::time::timeout;
use xai_fsnotify::{FsConfig, FsEvent, FsEventKind, FsEventSource};
fn fake_git_repo() -> TempDir {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
fs::create_dir(&git_dir).unwrap();
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::create_dir_all(git_dir.join("objects")).unwrap();
fs::create_dir_all(git_dir.join("refs")).unwrap();
temp
}
fn fake_sl_repo() -> TempDir {
let temp = TempDir::new().unwrap();
let sl_dir = temp.path().join(".sl");
fs::create_dir(&sl_dir).unwrap();
fs::write(sl_dir.join("dirstate"), sl_dirstate(0x11)).unwrap();
temp
}
/// `.sl/dirstate` = p1(20) ‖ p2(NULL_ID, 20) ‖ "\ntreestate\n"; only the
/// leading p1 (working-copy parent) is read by the source.
fn sl_dirstate(p1_byte: u8) -> Vec<u8> {
let mut v = vec![p1_byte; 20];
v.extend_from_slice(&[0u8; 20]);
v.extend_from_slice(b"\ntreestate\n");
v
}
async fn recv_until(
rx: &mut broadcast::Receiver<FsEvent>,
pred: impl Fn(&FsEvent) -> bool,
) -> FsEvent {
loop {
match rx.recv().await {
Ok(e) if pred(&e) => return e,
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => panic!("channel closed"),
}
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_files_changed() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
fs::write(temp.path().join("hello.txt"), "world").unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::FilesChanged { .. })),
)
.await
.unwrap();
match event {
FsEvent::FilesChanged { kind, paths } => {
assert!(matches!(kind, FsEventKind::Created | FsEventKind::Modified));
assert!(paths.iter().any(|p| p.ends_with("hello.txt")));
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_git_op_started_and_completed_no_head_change() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let lock = temp.path().join(".git/index.lock");
fs::write(&lock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
fs::remove_file(&lock).unwrap();
let completed = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match completed {
FsEvent::GitOperationCompleted { head_changed } => assert!(!head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_completed_with_head_change_on_branch_switch() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let lock = temp.path().join(".git/index.lock");
fs::write(&lock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
fs::write(temp.path().join(".git/HEAD"), "ref: refs/heads/feature\n").unwrap();
fs::remove_file(&lock).unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match event {
FsEvent::GitOperationCompleted { head_changed } => assert!(head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_sl_op_started_and_completed_no_head_change() {
let temp = fake_sl_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let wlock = temp.path().join(".sl/wlock");
fs::write(&wlock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
// Release without moving p1 (e.g. a dirty-treestate `sl status`).
fs::remove_file(&wlock).unwrap();
let completed = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match completed {
FsEvent::GitOperationCompleted { head_changed } => assert!(!head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_completed_with_head_change_on_sl_goto() {
let temp = fake_sl_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let wlock = temp.path().join(".sl/wlock");
fs::write(&wlock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
// Move the working-copy parent (p1) before releasing wlock, then release.
// `read_head` reads the new p1 on demand when the wlock-removal is processed.
fs::write(temp.path().join(".sl/dirstate"), sl_dirstate(0x22)).unwrap();
fs::remove_file(&wlock).unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match event {
FsEvent::GitOperationCompleted { head_changed } => assert!(head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn shared_dedupes_by_directory() {
use std::sync::Arc;
let temp = TempDir::new().unwrap();
let path = temp.path().to_path_buf();
// First call creates the watcher; subsequent calls for the same canonical
// directory hand back clones of the *same* source rather than opening a
// new OS watch. Skip gracefully where the OS denies watches (CI limits).
let Ok(a) = xai_fsnotify::shared(path.clone(), FsConfig::default()) else {
eprintln!("skipping: OS watcher unavailable (resource limit?)");
return;
};
let before = xai_fsnotify::stats();
let b = xai_fsnotify::shared(path.clone(), FsConfig::default()).unwrap();
assert!(Arc::ptr_eq(&a, &b), "same dir must share one watcher");
assert_eq!(
Arc::strong_count(&a),
2,
"second shared() must clone the existing source, not create a new one"
);
// The reuse must be counted as a cache hit (no new OS watcher created).
let after = xai_fsnotify::stats();
assert_eq!(
after.reused_total - before.reused_total,
1,
"reuse must increment reused_total"
);
assert_eq!(
after.created_total, before.created_total,
"reuse must not create a new watcher"
);
assert!(after.live_watchers >= 1, "the shared watcher must be live");
// A different directory gets its own independent watcher (a real miss).
let other = TempDir::new().unwrap();
let c = xai_fsnotify::shared(other.path().to_path_buf(), FsConfig::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &c), "different dirs must not share");
assert_eq!(
xai_fsnotify::stats().created_total - after.created_total,
1,
"a new directory must create a new watcher"
);
// Once the last sharer drops, the registry entry is reclaimed and a later
// request rebuilds a fresh source (exercises the recreate-after-drop path).
drop(a);
drop(b);
let d = xai_fsnotify::shared(path, FsConfig::default()).unwrap();
assert_eq!(
Arc::strong_count(&d),
1,
"after all sharers drop, shared() must build a new source"
);
}
/// Runnable measurement: simulates many sessions/subagents all watching the
/// same working directory and prints how many OS watchers were saved.
///
/// ```bash
/// cargo test -p xai-fsnotify --test integration \
/// shared_watcher_scaling_demo -- --nocapture
/// ```
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn shared_watcher_scaling_demo() {
const SESSIONS: usize = 50;
let temp = TempDir::new().unwrap();
let path = temp.path().to_path_buf();
let before = xai_fsnotify::stats();
// Hold every handle alive, mirroring N concurrent sessions on one cwd.
let mut handles = Vec::with_capacity(SESSIONS);
for _ in 0..SESSIONS {
let Ok(src) = xai_fsnotify::shared(path.clone(), FsConfig::default()) else {
eprintln!("skipping: OS watcher unavailable (resource limit?)");
return;
};
handles.push(src);
}
let after = xai_fsnotify::stats();
let created = after.created_total - before.created_total;
let reused = after.reused_total - before.reused_total;
println!(
"shared watcher scaling: {SESSIONS} sessions on one cwd -> \
created={created}, reused(saved)={reused}, live_watchers={}",
after.live_watchers
);
println!(
" before sharing this needed {SESSIONS} OS watchers; after sharing it needs {created}."
);
// One real OS watch for the whole fleet; the rest are cache hits.
assert_eq!(created, 1, "all sessions on one cwd share a single watcher");
assert_eq!(reused, (SESSIONS - 1) as u64);
assert!(after.live_watchers >= 1);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_works_in_non_git_workspace() {
let temp = TempDir::new().unwrap();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
fs::write(temp.path().join("hi.txt"), "x").unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::FilesChanged { .. })),
)
.await
.unwrap();
assert!(matches!(event, FsEvent::FilesChanged { .. }));
}