//! Session-level fs-watch policy over [`kigi_fsnotify`]. //! //! Mechanism (OS watch, coalesce, refcount) lives in `kigi_fsnotify`. This module //! decides which consumers exist, fans events through three explicit phases, and //! owns one `select!` loop (event hot path + debounced refresh). use std::cell::Cell; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; use agent_client_protocol as acp; use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender; use kigi_fsnotify::{FsEvent, FsEventKind}; use kigi_hunk_tracker::HunkTrackerHandle; use kigi_workspace::file_system::{CodebaseIndexManager, FileIndex, WalkOptions}; use tokio::sync::mpsc; use tokio::time::sleep_until; use crate::session::acp_session::SessionActor; use crate::session::persistence::PersistenceMsg; use crate::session::{ClientFsConfig, ClientFsMode}; // ── shared helpers ──────────────────────────────────────────────────────── /// True if `path` lies under a hidden component below `cwd`. Only the part /// *below* `cwd` is inspected — a hidden ancestor of `cwd` itself is ignored. pub(crate) fn is_under_hidden_dir(path: &Path, cwd: &Path) -> bool { let has_hidden = |rel: &Path| { rel.components().any(|c| { c.as_os_str() .to_str() .is_some_and(|s| s.starts_with('.') && s.len() > 1) }) }; if let Ok(rel) = path.strip_prefix(cwd) { return has_hidden(rel); } // Spelling mismatch (symlink/relative): retry on canonical roots before // giving up — never scan the whole absolute path. if let (Ok(p), Ok(c)) = (dunce::canonicalize(path), dunce::canonicalize(cwd)) && let Ok(rel) = p.strip_prefix(&c) { return has_hidden(rel); } false } /// Forward fs event to hunk tracker, skipping hidden dirs. pub(crate) fn forward_to_hunk_tracker( paths: &[PathBuf], kind: FsEventKind, handle: &HunkTrackerHandle, cwd: &Path, ) { for path in paths { if is_under_hidden_dir(path, cwd) { continue; } match kind { FsEventKind::Created | FsEventKind::Modified | FsEventKind::Renamed => { handle.handle_file_change(path.clone()); } FsEventKind::Removed => { handle.handle_file_deleted(path.clone()); } _ => {} } } } /// Dedup key for `x.ai/git_head_changed`, shared by the watcher's `GitHead` /// consumer and the post-edit `maybe_notify_git_branch` path so both compute /// the same identity (branch | is_worktree | main_repo). pub(crate) fn git_head_dedup_key( branch: Option<&str>, is_worktree: bool, main_repo: Option<&str>, ) -> String { // NUL separator: illegal in git refs and paths, so fields can't collide. format!( "{}\0{}\0{}", branch.unwrap_or(""), is_worktree, main_repo.unwrap_or("") ) } /// Convert to codebase graph `FileEvent` for incremental index updates. fn fs_event_to_codebase_graph_event( paths: &[PathBuf], kind: FsEventKind, ) -> kigi_codebase_graph::FileEvent { use kigi_codebase_graph::{FileEvent, FileEventKind}; let kind = match kind { FsEventKind::Created => FileEventKind::Created, FsEventKind::Modified => FileEventKind::Modified, FsEventKind::Removed => FileEventKind::Removed, FsEventKind::Renamed => FileEventKind::Renamed, _ => FileEventKind::Modified, }; FileEvent::new(paths.to_vec(), kind) } /// Convert to `FileIndexDelta` for TUI file tree updates. fn fs_event_to_delta( paths: &[PathBuf], kind: FsEventKind, root: &Path, ) -> kigi_workspace::file_system::FileIndexDelta { use kigi_workspace::file_system::FileIndexDelta; let stripped: Vec = paths .iter() .filter_map(|p| { if let Ok(rel) = p.strip_prefix(root) { return Some(rel.to_string_lossy().to_string()); } if let (Ok(p_canon), Ok(root_canon)) = (dunce::canonicalize(p), dunce::canonicalize(root)) && let Ok(rel) = p_canon.strip_prefix(&root_canon) { return Some(rel.to_string_lossy().to_string()); } tracing::debug!("fs_event_to_delta: could not strip {:?} from {:?}", root, p); None }) .filter(|p| !p.is_empty()) .collect(); match kind { FsEventKind::Created => { FileIndexDelta::Add(stripped.into_iter().map(|p| (p, false)).collect()) } FsEventKind::Removed => FileIndexDelta::Remove(stripped), FsEventKind::Renamed => { if stripped.len() >= 2 { FileIndexDelta::Batch(vec![ FileIndexDelta::Remove(vec![stripped[0].clone()]), FileIndexDelta::Add(vec![(stripped[1].clone(), false)]), ]) } else if stripped.len() == 1 { FileIndexDelta::Add(stripped.into_iter().map(|p| (p, false)).collect()) } else { FileIndexDelta::Batch(vec![]) } } FsEventKind::Modified => FileIndexDelta::Batch(vec![]), _ => FileIndexDelta::Batch(vec![]), } } const GIT_DIFF_REBUILD_THRESHOLD: usize = 500; fn parse_diff_name_status_line( line: &str, repo_root: &Path, ) -> Option { let mut parts = line.splitn(3, '\t'); let status = parts.next()?.trim(); let path = parts.next()?; match status.chars().next()? { 'A' => Some(kigi_codebase_graph::FileEvent::created( repo_root.join(path), )), 'D' => Some(kigi_codebase_graph::FileEvent::removed( repo_root.join(path), )), 'R' | 'C' => { let new_path = parts.next()?; Some(kigi_codebase_graph::FileEvent::renamed( repo_root.join(path), repo_root.join(new_path), )) } _ => Some(kigi_codebase_graph::FileEvent::modified( repo_root.join(path), )), } } /// After a HEAD change, diff ORIG_HEAD..HEAD and send targeted events /// to the codebase graph. Falls back to full rebuild if too many changes. async fn refresh_codebase_graph_after_head_change( idx: &kigi_codebase_graph::IndexManagerHandle, repo_root: &Path, ) { let mut cmd = tokio::process::Command::new("git"); cmd.args(["diff", "--name-status", "ORIG_HEAD", "HEAD"]) .current_dir(repo_root) .stdin(std::process::Stdio::null()); kigi_tools::util::detach_command(&mut cmd); cmd.envs(kigi_tools::util::pager_env()); let diff_output = cmd.output().await; match diff_output { Ok(output) if output.status.success() => { let changed: Vec<_> = String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .filter_map(|l| parse_diff_name_status_line(l, repo_root)) .collect(); let count = changed.len(); if count > GIT_DIFF_REBUILD_THRESHOLD { tracing::debug!( "git_refresh: {count} changed files exceeds threshold, falling back to rebuild" ); if let Err(e) = idx.rebuild() { tracing::debug!("git_refresh: rebuild failed: {:?}", e); } } else if let Err(e) = idx.send_events(changed) { tracing::debug!("git_refresh: failed to send graph events: {:?}", e); } else { tracing::debug!("git_refresh: sent {count} changed files to codebase graph"); } } _ => { tracing::debug!("git_refresh: git diff failed, falling back to rebuild"); if let Err(e) = idx.rebuild() { tracing::debug!("git_refresh: rebuild fallback also failed: {:?}", e); } } } } // ── capabilities / deps / plan ──────────────────────────────────────────── /// What the client wants from the watcher: pure, `Copy`, mode-independent. /// Proxy/hub gating of the actual spawn lives at the call site, not here. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct FsWatchCapabilities { pub client_notify: bool, pub hunk_tracking: bool, pub code_nav: bool, pub git_head: bool, } impl FsWatchCapabilities { /// True if any consumer needs the watcher. pub(crate) fn needs_watcher(self) -> bool { self.client_notify || self.hunk_tracking || self.code_nav || self.git_head } pub(crate) fn none() -> Self { Self::default() } /// Resolve the client's advertised signals into capabilities. pub(crate) fn resolve(inputs: CapabilityInputs) -> Self { Self { client_notify: inputs.client_notify, hunk_tracking: inputs.hunk_tracking, code_nav: inputs.code_nav, git_head: inputs.git_head_changed.unwrap_or(false), } } } /// Per-consumer signals fed to [`FsWatchCapabilities::resolve`]. Named fields /// (not positional bools) so call sites can't silently transpose them. pub(crate) struct CapabilityInputs { pub client_notify: bool, pub hunk_tracking: bool, pub code_nav: bool, /// `x.ai/gitHeadChanged`; opt-in (absent => off). pub git_head_changed: Option, } /// Handles gathered only when actually building consumers. pub(crate) struct FsWatchDeps { pub gateway: GatewaySender, pub session_id: String, pub cwd: PathBuf, pub index_root: PathBuf, pub hunk_tracker: HunkTrackerHandle, pub hunk_tracking_enabled: bool, pub codebase_indexes: Arc>, pub client_fs_config: Option, pub persistence_tx: mpsc::UnboundedSender, pub last_reported_branch: Arc>>, } impl FsWatchDeps { pub(crate) fn from_session( session: &SessionActor, client_fs_config: Option, codebase_indexes: Arc>, index_root: PathBuf, ) -> Self { Self { gateway: session.notifications.gateway.clone(), session_id: session.session_info.id.to_string(), cwd: session.tool_context.cwd.to_path_buf(), index_root, hunk_tracker: session.tool_context.hunk_tracker_handle.clone(), hunk_tracking_enabled: session.tool_context.hunk_tracking_enabled, codebase_indexes, client_fs_config, persistence_tx: session.notifications.persistence_tx.clone(), last_reported_branch: session.last_reported_branch.clone(), } } } // ── consumers (private, async, !Send — session LocalSet) ────────────────── struct ClientNotify { gateway: GatewaySender, session_id: String, cwd: PathBuf, mode: ClientFsMode, } impl ClientNotify { async fn on_change(&self, paths: &[PathBuf], kind: FsEventKind) { use serde_json::value::to_raw_value; match self.mode { ClientFsMode::Events => { // Present-tense strings are the `x.ai/fs_notify` wire protocol; // do not sync to internal variant names. let kind_str = match kind { FsEventKind::Created => "Create", FsEventKind::Modified => "Modify", FsEventKind::Removed => "Remove", FsEventKind::Renamed => "Rename", _ => "Other", }; let path_strs: Vec = paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); let params = serde_json::json!({ "sessionId": self.session_id, "event": { "kind": kind_str, "paths": path_strs }, }); if let Ok(raw) = to_raw_value(¶ms) { self.gateway .forward_fire_and_forget(acp::ExtNotification::new( "x.ai/fs_notify", raw.into(), )); } } ClientFsMode::Index => { if kind == FsEventKind::Modified { return; } let delta = fs_event_to_delta(paths, kind, &self.cwd); tracing::debug!( "fs_notify delta for {:?}: {:?}, is_empty={}", kind, delta.to_json(), delta.is_empty() ); if delta.is_empty() { return; } let params = serde_json::json!({ "sessionId": self.session_id, "delta": delta.to_json(), }); if let Ok(raw) = to_raw_value(¶ms) { self.gateway .forward_fire_and_forget(acp::ExtNotification::new( "x.ai/fs/index/delta", raw.into(), )); } } } } async fn send_initial_file_index(&self) { const FILE_INDEX_CHUNK_SIZE: usize = 500; let session_id = &self.session_id; let cwd = &self.cwd; let (index_res, build_elapsed_ms) = crate::timed!({ FileIndex::from_walk_with_options(cwd, WalkOptions::default()) }); let index = match index_res { Ok(idx) => idx, Err(e) => { tracing::warn!("failed to build file index for {:?}: {:?}", cwd, e); return; } }; let total_entries = index.len(); tracing::info!( "Built file index with {} entries in {}ms", total_entries, build_elapsed_ms ); let entries: Vec<_> = index.iter().collect(); let total_chunks = entries.len().div_ceil(FILE_INDEX_CHUNK_SIZE); let (_, send_elapsed_ms) = crate::timed!({ for (chunk_idx, chunk) in entries.chunks(FILE_INDEX_CHUNK_SIZE).enumerate() { let is_complete = chunk_idx == total_chunks - 1; let files: Vec = chunk .iter() .map(|(path, is_dir)| { serde_json::json!({ "path": path, "isDir": is_dir }) }) .collect(); let params = serde_json::json!({ "sessionId": session_id, "root": cwd.to_string_lossy(), "files": files, "chunk": chunk_idx, "totalChunks": total_chunks, "totalFiles": total_entries, "complete": is_complete, }); if let Ok(raw) = serde_json::value::to_raw_value(¶ms) { self.gateway .forward_fire_and_forget(acp::ExtNotification::new( "x.ai/fs/index", raw.into(), )); } if !is_complete { tokio::task::yield_now().await; } } }); tracing::info!( "Sent file index ({} entries in {} chunks) in {}ms", total_entries, total_chunks, send_elapsed_ms ); } } #[derive(Clone)] struct HunkTracking { handle: HunkTrackerHandle, cwd: PathBuf, /// Live per-event forward needs a git root (baselines are HEAD-relative). git_root: Option, } impl HunkTracking { fn on_change(&self, paths: &[PathBuf], kind: FsEventKind) { if self.git_root.is_none() { return; } forward_to_hunk_tracker(paths, kind, &self.handle, &self.cwd); } fn reconcile(&self) { self.handle.refresh_all_baselines(); } } #[derive(Clone)] struct CodebaseIndex { indexes: Arc>, root: PathBuf, } impl CodebaseIndex { fn on_change(&self, paths: &[PathBuf], kind: FsEventKind) { if let Some(idx) = self.indexes.lock().get(&self.root) { let _ = idx.send_event(fs_event_to_codebase_graph_event(paths, kind)); } } async fn rebuild_after_head_change(&self) { let idx = self.indexes.lock().get(&self.root); if let Some(idx) = idx { refresh_codebase_graph_after_head_change(&idx, &self.root).await; } } } #[derive(Clone)] struct GitHead { gateway: GatewaySender, session_id: String, cwd: PathBuf, persistence_tx: mpsc::UnboundedSender, /// Dedup slot shared with `SessionActor::maybe_notify_git_branch` (see /// `git_head_dedup_key`). last: Arc>>, } impl GitHead { /// Notify the client on branch/worktree/repo change; also persist commit/branch. async fn emit(&self) { let branch = kigi_workspace::session::git::get_branch(&self.cwd).await; let commit = kigi_workspace::session::git::get_current_commit(&self.cwd).await; let worktree = kigi_workspace::session::git::get_worktree_info(&self.cwd).await; let (is_worktree, main_repo) = worktree.unwrap_or((false, None)); let dedup_key = git_head_dedup_key(branch.as_deref(), is_worktree, main_repo.as_deref()); let changed = { let mut last = self.last.lock(); if last.as_deref() == Some(&dedup_key) { false } else { *last = Some(dedup_key); true } }; if changed { let params = kigi_workspace::session::git::GitHeadChanged { session_id: self.session_id.clone(), branch: branch.clone(), is_worktree, main_repo, }; if let Ok(raw) = serde_json::value::to_raw_value(¶ms) { self.gateway .forward_fire_and_forget(acp::ExtNotification::new( "x.ai/git_head_changed", raw.into(), )); } } let _ = self .persistence_tx .send(PersistenceMsg::GitHead { commit, branch }); } } // ── plan ────────────────────────────────────────────────────────────────── /// Live consumer set. Built only when `needs_watcher()`. pub(crate) struct FsWatchPlan { client_notify: Option, hunk: Option, index: Option, git_head: Option, fs_config: kigi_fsnotify::FsConfig, cwd: PathBuf, } impl FsWatchPlan { pub(crate) fn build(caps: FsWatchCapabilities, deps: FsWatchDeps) -> Self { let cfg = deps.client_fs_config.unwrap_or_default(); let mode = cfg.mode; let fs_config = cfg.fs; let client_notify = caps.client_notify.then(|| ClientNotify { gateway: deps.gateway.clone(), session_id: deps.session_id.clone(), cwd: deps.cwd.clone(), mode, }); let hunk = (caps.hunk_tracking && deps.hunk_tracking_enabled).then(|| { let git_root = kigi_workspace::session::git::find_git_root_from_path(&deps.cwd).ok(); HunkTracking { handle: deps.hunk_tracker, cwd: deps.cwd.clone(), git_root, } }); // Index handle looked up per-event so a later-created index still receives events. let index = caps.code_nav.then(|| CodebaseIndex { indexes: deps.codebase_indexes, root: deps.index_root, }); // Built only when the client opted into git-head notifications. The // restore-code consumer (the pager) advertises it, so it persists HEAD // here on every refresh; the end-of-turn `PersistGitHead` is a // best-effort fallback (trace-gated), not the load-bearing path. let git_head = caps.git_head.then(|| GitHead { gateway: deps.gateway, session_id: deps.session_id, cwd: deps.cwd.clone(), persistence_tx: deps.persistence_tx, last: deps.last_reported_branch, }); Self { client_notify, hunk, index, git_head, fs_config, cwd: deps.cwd, } } // Phase subsets differ on purpose — replay excludes hunk (baselines on refresh). async fn on_files_changed(&self, paths: &[PathBuf], kind: FsEventKind) { if let Some(h) = &self.hunk { h.on_change(paths, kind); } if let Some(i) = &self.index { i.on_change(paths, kind); } if let Some(c) = &self.client_notify { c.on_change(paths, kind).await; } } async fn on_replayed_change(&self, paths: &[PathBuf], kind: FsEventKind) { if let Some(i) = &self.index { i.on_change(paths, kind); } if let Some(c) = &self.client_notify { c.on_change(paths, kind).await; } } /// A self-contained refresh (clones the handles it needs) so it runs on its /// own `spawn_local` without blocking the event loop's `recv`. fn refresh_future( &self, rebuild_index: bool, ) -> impl std::future::Future + 'static { let hunk = self.hunk.clone(); let index = self.index.clone(); let git_head = self.git_head.clone(); async move { if let Some(h) = &hunk { h.reconcile(); } if rebuild_index && let Some(i) = &index { i.rebuild_after_head_change().await; } if let Some(g) = &git_head { g.emit().await; } } } } // ── op buffer / debounce ────────────────────────────────────────────────── type FsBatch = (Vec, FsEventKind); /// Resolve a completed git op: report the batches to replay plus whether a /// codebase-graph rebuild is needed. Occupancy is sampled *before* the buffer /// is drained, so an EdenFS-degraded `goto` (`head_changed: false` but a /// replayed flood) still requests the rebuild. fn resolve_completed_op(head_changed: bool, op_buffer: &mut Vec) -> (Vec, bool) { let had_buffered = !op_buffer.is_empty(); let replay = if head_changed { op_buffer.clear(); Vec::new() } else { std::mem::take(op_buffer) }; (replay, head_changed || had_buffered) } /// Cap on batches buffered during a git op; exceeding it means the completion /// boundary was lost (crashed git / stale `.git` lock) or a settle-merged op /// is bigger than we are willing to buffer and replay, so recover with a /// rebuild refresh instead of buffering forever. const MAX_OP_BUFFER: usize = 10_000; /// What the loop should do with one fs event. Pure (no I/O, no timers) so the /// `in_op`/`op_buffer` state machine is unit-testable; the loop performs the /// async consumer work and owns the debounce timer. enum Outcome { /// Op started, event buffered, or ignored — nothing to do now. Buffered, /// Forward a live change to the consumers. Forward(FsBatch), /// Replay these batches, then refresh (rebuild the graph if set). Completed { replay: Vec, rebuild: bool }, /// Refresh only (rebuild if set) — git-metadata change or a resync. Refresh { rebuild: bool }, } fn on_event(ev: FsEvent, in_op: &mut bool, op_buffer: &mut Vec) -> Outcome { match ev { FsEvent::GitOperationStarted => { *in_op = true; Outcome::Buffered } FsEvent::GitOperationCompleted { head_changed } => { *in_op = false; let (replay, rebuild) = resolve_completed_op(head_changed, op_buffer); Outcome::Completed { replay, rebuild } } FsEvent::FilesChanged { paths, kind } if *in_op => { if op_buffer.len() >= MAX_OP_BUFFER { op_buffer.clear(); *in_op = false; Outcome::Refresh { rebuild: true } } else { op_buffer.push((paths, kind)); Outcome::Buffered } } FsEvent::FilesChanged { paths, kind } => Outcome::Forward((paths, kind)), FsEvent::GitMetaChanged { .. } => Outcome::Refresh { rebuild: true }, _ => Outcome::Buffered, } } /// Recovery after a dropped broadcast event (`Lagged`): a missed GitOperation /// boundary could leave `in_op` stuck and `op_buffer` orphaned. fn on_resync(in_op: &mut bool, op_buffer: &mut Vec) -> Outcome { *in_op = false; op_buffer.clear(); Outcome::Refresh { rebuild: true } } const REFRESH_DEBOUNCE_QUIET: Duration = Duration::from_millis(500); const REFRESH_DEBOUNCE_MAX_WAIT: Duration = Duration::from_secs(3); /// Debounce state owned by the watcher loop (single task, no locking). struct Debounce { /// Cap from first event in the burst. max_deadline: Option, /// Quiet window; reset on each mid-burst notify. quiet_deadline: Option, } impl Debounce { fn idle() -> Self { Self { max_deadline: None, quiet_deadline: None, } } fn active(&self) -> bool { self.max_deadline.is_some() } /// Earliest of the quiet window and the max-wait cap — whichever fires first. fn next_deadline(&self) -> Option { match (self.quiet_deadline, self.max_deadline) { (Some(a), Some(b)) => Some(a.min(b)), (a, b) => a.or(b), } } /// Extend the quiet window, or start a fresh debounce if idle. fn bump(&mut self) { if self.active() { self.quiet_deadline = Some(tokio::time::Instant::now() + REFRESH_DEBOUNCE_QUIET); } else { self.arm(); } } fn arm(&mut self) { let now = tokio::time::Instant::now(); self.max_deadline = Some(now + REFRESH_DEBOUNCE_MAX_WAIT); self.quiet_deadline = Some(now + REFRESH_DEBOUNCE_QUIET); } fn clear(&mut self) { self.max_deadline = None; self.quiet_deadline = None; } } /// Give-up bound on consecutive in-op deferrals: at the 500ms quiet cadence /// this is ~60s, matching the watcher's stale-lock threshold. A crashed git /// leaving `.git/index.lock` parks the source in its locked state forever /// (no Completed, no resync, buffer never overflows on a quiet workspace), /// so without a bound the deferral would starve refreshes indefinitely; one /// forced fire per minute also caps the cost on pathologically long real ops. const MAX_CONSECUTIVE_IN_OP_DEFERS: u32 = 120; /// Decision for a due debounce deadline. Split out of the select loop's /// settle arm (pure bookkeeping, no I/O) so tests drive the real logic. enum SettleAction { /// Run the refresh now, with the accumulated rebuild request. Fire { rebuild: bool }, /// Not now — re-armed to retry after the next quiet window. Defer, } /// The settle arm's decision: defer while a git op is in flight (a debounce /// armed by pre-op edits or meta changes would otherwise scan a mid-op /// worktree; Completed bumps the window afterwards anyway) or while the /// single-flight refresh is still running (coalesce into the next pass); /// otherwise fire, taking the rebuild flag. In-op deferrals are bounded by /// [`MAX_CONSECUTIVE_IN_OP_DEFERS`] so a wedged op (stale lock file) cannot /// starve refreshes forever. fn on_settle_due( in_op: bool, refreshing: bool, debounce: &mut Debounce, rebuild_flag: &mut bool, in_op_defers: &mut u32, ) -> SettleAction { debounce.clear(); if refreshing { // Single-flight is self-limiting (the running refresh completes), so // it never counts toward the give-up bound. debounce.arm(); return SettleAction::Defer; } if in_op && *in_op_defers < MAX_CONSECUTIVE_IN_OP_DEFERS { *in_op_defers += 1; // Deferral re-arms in full, intentionally resetting the max-wait cap: // every deferral means a refresh is already owed and retried each // quiet window, so the cap's job (bounding un-fired bursts) is moot // here; the give-up bound above owns wedged-op starvation instead. debounce.arm(); return SettleAction::Defer; } *in_op_defers = 0; SettleAction::Fire { rebuild: std::mem::take(rebuild_flag), } } /// Bookkeeping shared by the `Completed`/`Refresh` outcome arms: record the /// rebuild request and (re)start the debounce quiet window. fn note_refresh_request(rebuild: bool, debounce: &mut Debounce, rebuild_flag: &mut bool) { *rebuild_flag |= rebuild; debounce.bump(); } // ── spawn / handle ──────────────────────────────────────────────────────── /// Resets the single-flight flag on drop (covers normal completion and panic). struct ResetOnDrop(Rc>); impl Drop for ResetOnDrop { fn drop(&mut self) { self.0.set(false); } } /// RAII drop-guard: dropping closes the shutdown channel and cancels the loop. pub(crate) struct FsWatchHandle { _shutdown_tx: mpsc::UnboundedSender<()>, } /// Spawn the watcher task; caller holds the handle for the session lifetime. pub(crate) fn spawn(plan: FsWatchPlan) -> FsWatchHandle { let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); let cwd = plan.cwd.clone(); let fs_config = plan.fs_config.clone(); let send_index_at_start = plan .client_notify .as_ref() .is_some_and(|c| c.mode == ClientFsMode::Index); tracing::debug!( ?cwd, client_notify = plan.client_notify.is_some(), "fs-notify: starting FsEventSource" ); tokio::task::spawn_local(async move { tokio::task::yield_now().await; let start_time = std::time::Instant::now(); let source = { let mut timer = crate::instrumentation_timer!("session.fs_notify_start"); timer.with_field("cwd", cwd.to_string_lossy().as_ref()); let init_cwd = cwd.clone(); let result = tokio::task::spawn_blocking(move || kigi_fsnotify::shared(init_cwd, fs_config)) .await; let ws = kigi_fsnotify::stats(); timer.with_field("live_watchers", ws.live_watchers as u64); timer.with_field("watchers_created_total", ws.created_total); timer.with_field("watchers_reused_total", ws.reused_total); result }; let source = match source { Ok(Ok(s)) => { tracing::debug!("FsEventSource ready in {:?}", start_time.elapsed()); s } Ok(Err(e)) => { tracing::warn!("failed to start FsEventSource: {e:?}"); return; } Err(join_err) => { tracing::warn!("FsEventSource init task failed: {join_err:?}"); return; } }; let mut events = source.subscribe(); if send_index_at_start && let Some(c) = &plan.client_notify { c.send_initial_file_index().await; } let mut rebuild_codebase_graph = false; let mut in_op = false; let mut op_buffer: Vec = Vec::new(); let mut debounce = Debounce::idle(); let mut in_op_defers = 0u32; // Single-flight: refresh runs on its own task so the loop keeps draining // events; a settle while one is in flight re-arms instead of overlapping. let refreshing = Rc::new(Cell::new(false)); loop { let settle = async { match debounce.next_deadline() { Some(d) => sleep_until(d).await, None => std::future::pending().await, } }; tokio::select! { biased; _ = shutdown_rx.recv() => { tracing::debug!("fs_notify: shutdown"); break; } _ = settle, if debounce.active() => { match on_settle_due( in_op, refreshing.get(), &mut debounce, &mut rebuild_codebase_graph, &mut in_op_defers, ) { SettleAction::Defer => {} SettleAction::Fire { rebuild } => { refreshing.set(true); let fut = plan.refresh_future(rebuild); let reset = ResetOnDrop(refreshing.clone()); tokio::task::spawn_local(async move { // Reset on completion OR panic so a panicking refresh // can't wedge single-flight for the session's life. let _reset = reset; fut.await; }); } } } ev_result = events.recv() => { let outcome = match ev_result { Ok(ev) => on_event(ev, &mut in_op, &mut op_buffer), Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::warn!("fs_events: dropped {n} (consumer lagged); resyncing"); on_resync(&mut in_op, &mut op_buffer) } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; match outcome { Outcome::Buffered => {} Outcome::Forward((paths, kind)) => { plan.on_files_changed(&paths, kind).await; } Outcome::Completed { replay, rebuild } => { for (paths, kind) in replay { plan.on_replayed_change(&paths, kind).await; } // Always refresh on completion: a same-branch commit // moves HEAD with no rebuild, but branch/commit still // need the pass. note_refresh_request(rebuild, &mut debounce, &mut rebuild_codebase_graph); } Outcome::Refresh { rebuild } => { note_refresh_request(rebuild, &mut debounce, &mut rebuild_codebase_graph); } } } } } }); FsWatchHandle { _shutdown_tx: shutdown_tx, } } // ── tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use kigi_workspace::file_system::FileIndexDelta; use std::path::PathBuf; #[test] fn fs_event_to_delta_create() { let root = PathBuf::from("/workspace"); let delta = fs_event_to_delta( &[PathBuf::from("/workspace/src/main.rs")], FsEventKind::Created, &root, ); match delta { FileIndexDelta::Add(entries) => { assert_eq!(entries.len(), 1); assert_eq!(entries[0].0, "src/main.rs"); } _ => panic!("Expected Add delta, got {:?}", delta), } } #[test] fn fs_event_to_delta_rename_pair() { let root = PathBuf::from("/workspace"); let delta = fs_event_to_delta( &[ PathBuf::from("/workspace/old.rs"), PathBuf::from("/workspace/new.rs"), ], FsEventKind::Renamed, &root, ); match delta { FileIndexDelta::Batch(deltas) => { assert_eq!(deltas.len(), 2); match &deltas[0] { FileIndexDelta::Remove(p) => assert_eq!(p[0], "old.rs"), _ => panic!("Expected Remove"), } match &deltas[1] { FileIndexDelta::Add(e) => assert_eq!(e[0].0, "new.rs"), _ => panic!("Expected Add"), } } _ => panic!("Expected Batch delta"), } } #[test] fn fs_event_to_delta_modify_is_empty() { let root = PathBuf::from("/workspace"); let delta = fs_event_to_delta( &[PathBuf::from("/workspace/file.rs")], FsEventKind::Modified, &root, ); assert!(delta.is_empty()); } #[test] fn is_under_hidden_dir_positive() { assert!(is_under_hidden_dir( &PathBuf::from("/workspace/.claude/worktrees/abc/src/main.rs"), &PathBuf::from("/workspace"), )); } #[test] fn is_under_hidden_dir_ignores_cwd_components() { assert!(!is_under_hidden_dir( &PathBuf::from("/home/user/.config/project/src/lib.rs"), &PathBuf::from("/home/user/.config/project"), )); } #[test] fn is_under_hidden_dir_unrelated_path_is_not_dropped() { // strip_prefix fails and the (nonexistent) paths can't canonicalize, so // we must NOT scan the absolute path and treat the `.cache` ancestor as // hidden — that would silently drop every event for a repo under one. assert!(!is_under_hidden_dir( &PathBuf::from("/home/user/.cache/repo/src/main.rs"), &PathBuf::from("/some/other/root"), )); } #[test] fn parse_diff_name_status() { use kigi_codebase_graph::FileEventKind; let root = Path::new("/repo"); let ev = parse_diff_name_status_line("M\tsrc/main.rs", root).unwrap(); assert_eq!(ev.kind, FileEventKind::Modified); let ev = parse_diff_name_status_line("A\tnew_file.rs", root).unwrap(); assert_eq!(ev.kind, FileEventKind::Created); let ev = parse_diff_name_status_line("D\told_file.rs", root).unwrap(); assert_eq!(ev.kind, FileEventKind::Removed); let ev = parse_diff_name_status_line("R100\told.rs\tnew.rs", root).unwrap(); assert_eq!(ev.kind, FileEventKind::Renamed); assert!(parse_diff_name_status_line("", root).is_none()); } #[test] fn needs_watcher_truth_table() { assert!(!FsWatchCapabilities::none().needs_watcher()); assert!(!FsWatchCapabilities::default().needs_watcher()); for field_setter in [ |c: &mut FsWatchCapabilities| c.client_notify = true, |c: &mut FsWatchCapabilities| c.hunk_tracking = true, |c: &mut FsWatchCapabilities| c.code_nav = true, |c: &mut FsWatchCapabilities| c.git_head = true, ] { let mut caps = FsWatchCapabilities::default(); field_setter(&mut caps); assert!(caps.needs_watcher()); } } fn inputs(on: bool, git_head_changed: Option) -> CapabilityInputs { CapabilityInputs { client_notify: on, hunk_tracking: on, code_nav: on, git_head_changed, } } #[test] fn resolve_reflects_capabilities() { // git_head is opt-in: absent => off, advertised value otherwise. assert!(!FsWatchCapabilities::resolve(inputs(false, None)).git_head); assert!(FsWatchCapabilities::resolve(inputs(false, Some(true))).git_head); assert!(!FsWatchCapabilities::resolve(inputs(false, Some(false))).git_head); // Distinct per-field values catch a field-mapping transposition in resolve. let caps = FsWatchCapabilities::resolve(CapabilityInputs { client_notify: true, hunk_tracking: false, code_nav: true, git_head_changed: Some(false), }); assert!(caps.client_notify && !caps.hunk_tracking && caps.code_nav && !caps.git_head); } #[test] fn git_head_dedup_key_identity() { let base = git_head_dedup_key(Some("main"), false, Some("/repo")); // Every dimension is part of the identity. assert_ne!(base, git_head_dedup_key(Some("dev"), false, Some("/repo"))); assert_ne!(base, git_head_dedup_key(Some("main"), true, Some("/repo"))); assert_ne!( base, git_head_dedup_key(Some("main"), false, Some("/other")) ); // Detached HEAD (None branch) is stable and distinct from a real branch. assert_eq!( git_head_dedup_key(None, false, None), git_head_dedup_key(None, false, None) ); assert_ne!(base, git_head_dedup_key(None, false, Some("/repo"))); // Swapping branch and main_repo must not collide (the NUL separator). assert_ne!( git_head_dedup_key(Some("a"), false, Some("b")), git_head_dedup_key(Some("b"), false, Some("a")), ); } #[test] fn hunk_not_built_when_actor_disabled() { let (tx, _rx) = mpsc::unbounded_channel(); let deps = FsWatchDeps { gateway: crate::test_support::lsp_runtime::test_gateway(), session_id: "s".into(), cwd: PathBuf::from("/repo"), index_root: PathBuf::from("/repo"), hunk_tracker: HunkTrackerHandle::noop(), hunk_tracking_enabled: false, codebase_indexes: Arc::new(parking_lot::Mutex::new(CodebaseIndexManager::new())), client_fs_config: None, persistence_tx: tx, last_reported_branch: Arc::new(parking_lot::Mutex::new(None)), }; let plan = FsWatchPlan::build( FsWatchCapabilities { hunk_tracking: true, ..Default::default() }, deps, ); assert!(plan.hunk.is_none()); } fn batch() -> FsBatch { ( vec![PathBuf::from("/repo/src/lib.rs")], FsEventKind::Modified, ) } /// Head moved: the buffered flood is stale, so it is discarded (no replay) /// and the rebuild is requested; the buffer ends empty. #[test] fn head_change_discards_buffer_and_requests_rebuild() { let mut op_buffer = vec![batch(), batch()]; let (replay, rebuild) = resolve_completed_op(true, &mut op_buffer); assert!(replay.is_empty()); assert!(rebuild); assert!(op_buffer.is_empty()); } #[test] fn head_change_empty_buffer_requests_rebuild() { let mut op_buffer: Vec = Vec::new(); let (replay, rebuild) = resolve_completed_op(true, &mut op_buffer); assert!(replay.is_empty()); assert!(rebuild); } /// EdenFS-degraded `goto`: head reads unchanged but a flood was buffered, so /// the batches are replayed AND the rebuild is requested. Guards the ordering: /// sampling occupancy after the drain would read empty and drop the rebuild. #[test] fn degraded_goto_replays_buffer_and_requests_rebuild() { let mut op_buffer = vec![batch(), batch()]; let (replay, rebuild) = resolve_completed_op(false, &mut op_buffer); assert_eq!(replay.len(), 2); assert!(rebuild); assert!(op_buffer.is_empty()); } #[test] fn no_op_completion_skips_rebuild() { let mut op_buffer: Vec = Vec::new(); let (replay, rebuild) = resolve_completed_op(false, &mut op_buffer); assert!(replay.is_empty()); assert!(!rebuild); } fn files_changed(path: &str) -> FsEvent { FsEvent::FilesChanged { paths: vec![PathBuf::from(path)], kind: FsEventKind::Modified, } } #[test] fn on_event_buffers_during_op_and_forwards_when_live() { let mut in_op = false; let mut buf = Vec::new(); assert!(matches!( on_event(FsEvent::GitOperationStarted, &mut in_op, &mut buf), Outcome::Buffered )); assert!(in_op); // In-op changes accumulate; live changes forward. assert!(matches!( on_event(files_changed("/r/a"), &mut in_op, &mut buf), Outcome::Buffered )); assert_eq!(buf.len(), 1); in_op = false; assert!(matches!( on_event(files_changed("/r/b"), &mut in_op, &mut buf), Outcome::Forward(_) )); assert_eq!(buf.len(), 1, "live change is not buffered"); } #[test] fn on_event_completed_drains_and_clears_in_op() { let mut in_op = true; let mut buf = vec![batch(), batch()]; match on_event( FsEvent::GitOperationCompleted { head_changed: false, }, &mut in_op, &mut buf, ) { Outcome::Completed { replay, rebuild } => { assert_eq!(replay.len(), 2); assert!(rebuild); } _ => panic!("expected Completed"), } assert!(!in_op); assert!(buf.is_empty()); } #[test] fn on_event_op_buffer_cap_recovers() { // A lost completion boundary would otherwise grow the buffer forever and // swallow every event; the cap recovers (unstick, clear, force rebuild). let mut in_op = true; let mut buf: Vec = (0..MAX_OP_BUFFER).map(|_| batch()).collect(); match on_event(files_changed("/r/a"), &mut in_op, &mut buf) { Outcome::Refresh { rebuild } => assert!(rebuild), _ => panic!("expected Refresh recovery"), } assert!(!in_op); assert!(buf.is_empty()); } #[test] fn on_resync_unsticks_and_refreshes() { let mut in_op = true; let mut buf = vec![batch()]; match on_resync(&mut in_op, &mut buf) { Outcome::Refresh { rebuild } => assert!(rebuild), _ => panic!("expected Refresh"), } assert!(!in_op); assert!(buf.is_empty()); } #[test] fn next_deadline_picks_earlier_of_quiet_and_max() { let mut d = Debounce::idle(); assert_eq!(d.next_deadline(), None); d.arm(); // Fresh arm: the quiet window precedes the max-wait cap. assert!(d.quiet_deadline < d.max_deadline); assert_eq!(d.next_deadline(), d.quiet_deadline); // Sustained burst pushes quiet past the cap; the cap forces the refresh. d.quiet_deadline = Some(d.max_deadline.unwrap() + REFRESH_DEBOUNCE_QUIET); assert_eq!(d.next_deadline(), d.max_deadline); d.clear(); assert!(!d.active()); } #[tokio::test(start_paused = true)] async fn debounce_bump_arms_then_extends_quiet_keeping_cap() { let mut d = Debounce::idle(); d.bump(); // idle -> arm assert!(d.active()); let (quiet0, max0) = (d.quiet_deadline.unwrap(), d.max_deadline.unwrap()); tokio::time::advance(Duration::from_millis(100)).await; d.bump(); // active -> extend the quiet window, keep the max-wait cap assert!(d.quiet_deadline.unwrap() > quiet0); assert_eq!(d.max_deadline.unwrap(), max0, "bump must not push the cap"); } /// Advance the paused clock to `target`, resolving every debounce /// deadline that falls due on the way through the real settle arm /// (`on_settle_due`) under the given `in_op`; count the fires. Deadlines /// coinciding with `target` resolve before the event at `target` is /// processed, matching the loop's biased select order. async fn advance_resolving_settles( target: tokio::time::Instant, debounce: &mut Debounce, in_op: bool, rebuild_flag: &mut bool, in_op_defers: &mut u32, fires: &mut usize, ) { loop { let now = tokio::time::Instant::now(); match debounce.next_deadline().filter(|d| *d <= target) { Some(d) => { if d > now { tokio::time::advance(d - now).await; } if let SettleAction::Fire { .. } = on_settle_due(in_op, false, debounce, rebuild_flag, in_op_defers) { *fires += 1; } } None => { if target > now { tokio::time::advance(target - now).await; } return; } } } } /// Feed the event stream a K-pick rebase produces through the real /// `on_event`/`note_refresh_request`/`on_settle_due` seams; return the /// number of refresh fires. Models fsnotify's settle semantics: the /// source holds Completed for `SETTLE_MS` after each unlock, so picks /// whose lock-free gap fits inside the settle window merge into a single /// Started/Completed pair, while slower cadences emit per-pick pairs /// whose Completed lags the unlock by the settle window. async fn run_rebase_cadence( picks: usize, pick_period: Duration, pick_duration: Duration, ) -> usize { let settle = Duration::from_millis(kigi_fsnotify::SETTLE_MS); // Uniform cadence: either every re-lock lands inside the previous // pick's settle window (one merged op) or none does (per-pick pairs). let merged = pick_period - pick_duration <= settle; let mut in_op = false; let mut op_buffer: Vec = Vec::new(); let mut debounce = Debounce::idle(); let mut rebuild_flag = false; let mut in_op_defers = 0u32; let mut fires = 0usize; let start = tokio::time::Instant::now(); for pick in 0..picks { let t_started = start + pick_period * pick as u32; let t_completed = t_started + pick_duration + settle; if pick == 0 || !merged { advance_resolving_settles( t_started, &mut debounce, in_op, &mut rebuild_flag, &mut in_op_defers, &mut fires, ) .await; assert!(matches!( on_event(FsEvent::GitOperationStarted, &mut in_op, &mut op_buffer), Outcome::Buffered )); } if pick == picks - 1 || !merged { advance_resolving_settles( t_completed, &mut debounce, in_op, &mut rebuild_flag, &mut in_op_defers, &mut fires, ) .await; match on_event( FsEvent::GitOperationCompleted { head_changed: true }, &mut in_op, &mut op_buffer, ) { Outcome::Completed { rebuild, .. } => { note_refresh_request(rebuild, &mut debounce, &mut rebuild_flag); } other => panic!( "expected Completed, got {:?}", std::mem::discriminant(&other) ), } } } // Drain the trailing window after the last pick. let idle_tail = REFRESH_DEBOUNCE_MAX_WAIT + REFRESH_DEBOUNCE_QUIET; advance_resolving_settles( start + pick_period * picks as u32 + idle_tail, &mut debounce, in_op, &mut rebuild_flag, &mut in_op_defers, &mut fires, ) .await; assert!(!debounce.active(), "debounce must be drained at the end"); fires } /// Picks slower than the settle window (1s apart, the big-repo rebase /// cadence): the source still emits per-pick pairs, but every quiet /// deadline lands inside the next pick's op window, so the settle arm /// defers it — the single refresh fires after the last pick completes. /// Before the in-op deferral this fired once per inter-pick gap. #[tokio::test(start_paused = true)] async fn rebase_cadence_defers_mid_op_and_fires_once() { let fires = run_rebase_cadence(6, Duration::from_millis(1000), Duration::from_millis(100)).await; assert_eq!(fires, 1, "one refresh per rebase, after the last pick"); } /// Picks faster than the settle window: fsnotify merges all 16 lock /// cycles into one operation, so the loop sees a single pair and fires a /// single refresh. Before the merge this hit the max-wait cap twice. #[tokio::test(start_paused = true)] async fn dense_rebase_cadence_merges_into_one_fire() { let fires = run_rebase_cadence(16, Duration::from_millis(400), Duration::from_millis(200)).await; assert_eq!(fires, 1, "one refresh for the merged operation"); } /// on_settle_due decision table: mid-op and mid-refresh defer (re-armed, /// rebuild request kept); otherwise fire, taking the request. #[tokio::test(start_paused = true)] async fn settle_due_defers_in_op_or_refreshing_and_fires_otherwise() { let mut d = Debounce::idle(); let mut rebuild = true; let mut defers = 0u32; d.arm(); assert!(matches!( on_settle_due(true, false, &mut d, &mut rebuild, &mut defers), SettleAction::Defer )); assert!(d.active(), "in-op deferral must re-arm"); assert!(rebuild, "in-op deferral must keep the rebuild request"); assert_eq!(defers, 1, "in-op deferral counts toward the give-up bound"); assert!(matches!( on_settle_due(false, true, &mut d, &mut rebuild, &mut defers), SettleAction::Defer )); assert!(d.active(), "single-flight deferral must re-arm"); assert_eq!(defers, 1, "single-flight deferral must not count"); match on_settle_due(false, false, &mut d, &mut rebuild, &mut defers) { SettleAction::Fire { rebuild } => assert!(rebuild, "fire carries the request"), SettleAction::Defer => panic!("idle settle must fire"), } assert!(!d.active(), "fire leaves the debounce cleared"); assert!(!rebuild, "fire takes the rebuild request"); assert_eq!(defers, 0, "fire resets the give-up counter"); } /// The give-up bound: a wedged op (stale `.git/index.lock`, no Completed /// ever) defers exactly [`MAX_CONSECUTIVE_IN_OP_DEFERS`] times, then the /// refresh fires anyway and the cycle restarts — refreshes are starved /// for at most ~a minute, not forever. #[tokio::test(start_paused = true)] async fn wedged_op_deferral_is_bounded() { let mut d = Debounce::idle(); let mut rebuild = true; let mut defers = 0u32; d.arm(); for i in 0..MAX_CONSECUTIVE_IN_OP_DEFERS { assert!( matches!( on_settle_due(true, false, &mut d, &mut rebuild, &mut defers), SettleAction::Defer ), "defer {i} must still hold back" ); } match on_settle_due(true, false, &mut d, &mut rebuild, &mut defers) { SettleAction::Fire { rebuild } => { assert!(rebuild, "the forced fire carries the pending request"); } SettleAction::Defer => panic!("the give-up bound must force a fire"), } assert_eq!(defers, 0, "forced fire restarts the bound"); } /// The max-wait cap still owns non-op bursts: sustained sub-quiet bumps /// (e.g. background-fetch ref churn arriving as GitMetaChanged) keep /// pushing the quiet window, so the 3s cap is what forces the refresh. #[tokio::test(start_paused = true)] async fn sustained_non_op_bumps_fire_on_max_wait_cap() { let mut debounce = Debounce::idle(); let mut rebuild_flag = false; let mut in_op_defers = 0u32; let mut fires = 0usize; let start = tokio::time::Instant::now(); // Bumps every 400ms (< 500ms quiet) from t=0 to t=2.8s: quiet never // elapses between bumps, so only the cap (armed at t=0, due t=3.0s, // before the last quiet deadline at 3.3s) can fire. for k in 0..8u32 { advance_resolving_settles( start + Duration::from_millis(400) * k, &mut debounce, false, &mut rebuild_flag, &mut in_op_defers, &mut fires, ) .await; note_refresh_request(true, &mut debounce, &mut rebuild_flag); } assert_eq!(fires, 0, "no quiet window elapsed during the burst"); advance_resolving_settles( start + Duration::from_millis(3050), &mut debounce, false, &mut rebuild_flag, &mut in_op_defers, &mut fires, ) .await; assert_eq!(fires, 1, "the max-wait cap must force the fire at 3s"); assert!(!debounce.active(), "cap fire clears the debounce"); } /// The residual mid-op hole: a debounce armed by pre-op activity (a /// git-meta refresh request here) must not fire while the op is in /// flight; the deferral re-arms it and the refresh runs exactly once /// after Completed, rebuild request intact. #[tokio::test(start_paused = true)] async fn debounce_armed_pre_op_defers_mid_op_and_fires_once_after_completed() { let mut in_op = false; let mut op_buffer: Vec = Vec::new(); let mut debounce = Debounce::idle(); let mut rebuild_flag = false; let mut defers = 0u32; match on_event( FsEvent::GitMetaChanged { kind: kigi_fsnotify::GitMetaKind::RefsChanged, }, &mut in_op, &mut op_buffer, ) { Outcome::Refresh { rebuild } => { note_refresh_request(rebuild, &mut debounce, &mut rebuild_flag); } other => panic!("expected Refresh, got {:?}", std::mem::discriminant(&other)), } // The op starts before the quiet window elapses. tokio::time::advance(Duration::from_millis(100)).await; assert!(matches!( on_event(FsEvent::GitOperationStarted, &mut in_op, &mut op_buffer), Outcome::Buffered )); // The pre-op deadline falls due mid-op: deferred, request kept. let due = debounce.next_deadline().expect("debounce armed pre-op"); tokio::time::advance(due - tokio::time::Instant::now()).await; assert!(matches!( on_settle_due(in_op, false, &mut debounce, &mut rebuild_flag, &mut defers), SettleAction::Defer )); assert!(debounce.active() && rebuild_flag); // Op completes (no head move, nothing buffered): the re-armed // deadline fires exactly once, with the preserved rebuild request. match on_event( FsEvent::GitOperationCompleted { head_changed: false, }, &mut in_op, &mut op_buffer, ) { Outcome::Completed { rebuild, .. } => { note_refresh_request(rebuild, &mut debounce, &mut rebuild_flag); } other => panic!( "expected Completed, got {:?}", std::mem::discriminant(&other) ), } let due = debounce.next_deadline().expect("bumped by Completed"); tokio::time::advance(due - tokio::time::Instant::now()).await; match on_settle_due(in_op, false, &mut debounce, &mut rebuild_flag, &mut defers) { SettleAction::Fire { rebuild } => { assert!(rebuild, "the pre-op rebuild request survives the deferral"); } SettleAction::Defer => panic!("op is over; the refresh must fire"), } assert!(!debounce.active(), "single fire, nothing left armed"); } }