use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::json;
use crate::errors::{ErrorContext, log_debug, log_warn};
use super::backend::WatchStartError;
use super::state::FileWatcher;
use super::types::{
EXHAUSTION_REASON, INOTIFY_LIMIT_REASON, MAX_LOCK_RETRIES, MAX_LOCK_RETRY_DELAY_MS,
ScheduledSyncKind, WatchSyncError,
};
use super::util::{context, now_ms, path_string};
impl FileWatcher {
pub(super) fn degrade(&mut self, reason: &str, mut context: ErrorContext) {
if self.degraded_reason.is_some() {
return;
}
self.degraded_reason = Some(reason.to_owned());
context.insert(
"projectRoot".to_owned(),
json!(path_string(&self.project_root)),
);
context.insert("reason".to_owned(), json!(reason));
log_warn("File watcher disabled", Some(&context));
if let Some(callback) = &self.on_degraded {
callback(reason);
}
self.stop();
}
pub(super) fn warn_inotify_limit(&mut self, mut context: ErrorContext) {
if self.inotify_limit_warned {
return;
}
self.inotify_limit_warned = true;
context.insert("watchedDirs".to_owned(), json!(self.dir_watchers.len()));
log_warn(INOTIFY_LIMIT_REASON, Some(&context));
}
pub(super) fn schedule_sync(&mut self) {
if self.scheduled_kind == Some(ScheduledSyncKind::Retry) {
return;
}
let now = Instant::now();
let epoch_start = *self.debounce_started_at.get_or_insert(now);
let deadline = epoch_start + Duration::from_millis(self.max_debounce_ms);
let remaining_to_deadline = deadline.saturating_duration_since(now).as_millis() as u64;
let delay = self.debounce_ms.min(remaining_to_deadline);
self.scheduled_at = Some(now);
self.scheduled_delay_ms = Some(delay);
self.scheduled_kind = Some(ScheduledSyncKind::Debounce);
}
fn schedule_retry_sync(&mut self, delay_ms: u64) {
self.scheduled_at = Some(Instant::now());
self.scheduled_delay_ms = Some(delay_ms);
self.scheduled_kind = Some(ScheduledSyncKind::Retry);
}
fn skipped_sync_retry_delay_ms(&self) -> u64 {
self.debounce_ms
.max(self.max_debounce_ms)
.max(self.min_sync_interval_ms)
}
pub(super) fn throttle_remaining_ms(&self) -> Option<u64> {
if self.min_sync_interval_ms == 0 {
return None;
}
let elapsed = self.last_sync_finished_at?.elapsed();
let interval = Duration::from_millis(self.min_sync_interval_ms);
if elapsed >= interval {
return None;
}
Some(((interval - elapsed).as_millis() as u64).max(1))
}
pub(super) fn flush(&mut self) {
if self.syncing || self.stopped {
return;
}
self.sync_started_ms = now_ms();
self.syncing = true;
self.debounce_started_at = None;
let result = (self.sync_fn)();
let mut skipped_sync_retry_delay_ms = None;
match result {
Ok(result) if result.skipped => {
self.lock_retry_count = 0;
self.last_sync_finished_at = Some(Instant::now());
skipped_sync_retry_delay_ms = Some(self.skipped_sync_retry_delay_ms());
let ctx = context([
("pendingFiles", json!(self.pending_files.len())),
("filesChanged", json!(result.files_changed)),
]);
log_warn(
"Watch sync skipped by the sync callback; keeping pending and retrying later.",
Some(&ctx),
);
}
Ok(result) => {
self.lock_retry_count = 0;
self.last_sync_finished_at = Some(Instant::now());
let sync_started_ms = self.sync_started_ms;
self.pending_files
.retain(|_, info| info.last_seen_ms > sync_started_ms);
if let Some(callback) = &self.on_sync_complete {
callback(&result);
}
}
Err(WatchSyncError::LockUnavailable(err)) => {
self.lock_retry_count += 1;
let ctx = context([
("pendingFiles", json!(self.pending_files.len())),
("retryCount", json!(self.lock_retry_count)),
]);
log_debug("Watch sync skipped: file lock unavailable", Some(&ctx));
if self.lock_retry_count > MAX_LOCK_RETRIES {
self.degrade(
"RustCodeGraph file lock held by another process past the retry budget; \
auto-sync disabled. Run `rustcodegraph sync` once the other writer finishes \
(or install git sync hooks) to refresh the graph.",
context([
("pendingFiles", json!(self.pending_files.len())),
("retryCount", json!(self.lock_retry_count)),
("error", json!(err.message())),
]),
);
}
}
Err(err) => {
self.lock_retry_count = 0;
let ctx = context([("error", json!(err.message()))]);
log_warn("Watch sync failed", Some(&ctx));
if let Some(callback) = &self.on_sync_error {
callback(&err);
}
}
}
self.syncing = false;
if !self.pending_files.is_empty() && !self.stopped {
if self.lock_retry_count > 0 {
let exponent = self.lock_retry_count.saturating_sub(1).min(31);
let multiplier = 1u64 << exponent;
let retry_delay_ms = self
.debounce_ms
.saturating_mul(multiplier)
.min(MAX_LOCK_RETRY_DELAY_MS);
self.schedule_retry_sync(retry_delay_ms);
} else if let Some(retry_delay_ms) = skipped_sync_retry_delay_ms {
self.schedule_retry_sync(retry_delay_ms);
} else {
self.schedule_sync();
}
}
}
pub(super) fn handle_start_error(&mut self, err: WatchStartError) {
match err {
WatchStartError::ResourceExhaustion(message) => {
self.degrade(EXHAUSTION_REASON, context([("error", json!(message))]));
}
other => {
let ctx = context([("error", json!(other.message()))]);
log_warn("Could not start file watcher", Some(&ctx));
self.stop();
}
}
}
pub(super) fn handle_runtime_watch_error(
&mut self,
err: WatchStartError,
dir: Option<PathBuf>,
) {
match err {
WatchStartError::ResourceExhaustion(message) => {
let mut ctx = context([("error", json!(message))]);
if let Some(dir) = &dir {
ctx.insert("dir".to_owned(), json!(path_string(dir)));
}
self.degrade(EXHAUSTION_REASON, ctx);
}
WatchStartError::InotifyExhaustion(message) => {
let mut ctx = context([("error", json!(message))]);
if let Some(dir) = &dir {
ctx.insert("dir".to_owned(), json!(path_string(dir)));
}
self.warn_inotify_limit(ctx);
if let Some(dir) = dir {
self.unwatch_dir(&dir);
}
}
WatchStartError::Other(message) => {
let mut ctx = context([("error", json!(message))]);
if let Some(dir) = &dir {
ctx.insert("dir".to_owned(), json!(path_string(dir)));
self.unwatch_dir(dir);
}
log_warn("File watcher error", Some(&ctx));
}
}
}
}