diff --git a/CHANGELOG.md b/CHANGELOG.md index aa00c7a..043e304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.10.5] - 2026-09-02 + +### Fixed + +- Kept polling sessions alive when a watched file is deleted, while preserving + delete-and-recreate events and leaving non-transient watcher errors fatal. + ## [0.10.4] - 2026-09-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 53e298a..693af28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.10.4" +version = "0.10.5" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 77a8b98..d5e8465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.10.4" +version = "0.10.5" edition = "2024" [dependencies] diff --git a/docs/behavior.md b/docs/behavior.md index 2c01e98..3aff514 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -69,6 +69,11 @@ watch-group patterns and watches only those files or directories. backend can be selected in config as a fallback for environments where native events are unreliable. - Literal file targets are watched as narrowly as the backend allows. + The polling backend scans each file's immediate parent so deleting and + recreating the file remains observable. A configured file, or a child beneath + a recursive target, that disappears during a scan is transient. Pathless and + registered-root errors remain fatal. Errors confined to unconfigured siblings + do not widen the watch group's failure boundary. Use a trailing `/` in the config when you mean an explicit directory target that should be watched recursively. diff --git a/src/engine.rs b/src/engine.rs index 93f57f7..52738e7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -6,8 +6,8 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use notify::{ - Config as NotifyConfig, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, - Watcher, + Config as NotifyConfig, ErrorKind as NotifyErrorKind, Event, EventKind, PollWatcher, + RecommendedWatcher, RecursiveMode, Watcher, event::{AccessKind, AccessMode}, }; use serde_json::{Map, Value}; @@ -170,12 +170,33 @@ impl Engine { } event = rx.recv() => { match event { - Some(result) => { - pending_watch_events.push(result?); + Some(Ok(event)) => { + pending_watch_events.push(event); if watch_deadline.is_none() { watch_deadline = Some(Instant::now() + self.config.debounce()); } } + Some(Err(error)) if is_transient_missing_path_error( + self.config.watcher.kind, + &adapter.watched_targets, + &error, + ) => { + warn!( + error = %error, + "watched path disappeared during a filesystem scan; continuing" + ); + } + Some(Err(error)) if is_unrelated_poll_error( + self.config.watcher.kind, + &adapter.watched_targets, + &error, + ) => { + warn!( + error = %error, + "polling backend reported an error outside configured watch targets; continuing" + ); + } + Some(Err(error)) => return Err(error.into()), None => return Err(anyhow!("watcher event channel disconnected")), } } @@ -645,7 +666,16 @@ fn resolve_watch_registrations( } registrations } - WatcherKind::Poll => vec![target.clone()], + WatcherKind::Poll => target + .path + .parent() + .map(|parent| { + vec![CompiledWatchTarget { + path: parent.to_path_buf(), + recursive: false, + }] + }) + .ok_or_else(|| anyhow!("watch target '{}' has no parent", target.path.display()))?, }); } @@ -666,6 +696,62 @@ fn resolve_watch_registrations( }]) } +fn is_transient_missing_path_error( + watcher_kind: WatcherKind, + watched_targets: &[CompiledWatchTarget], + error: ¬ify::Error, +) -> bool { + if watcher_kind != WatcherKind::Poll || error.paths.is_empty() { + return false; + } + let is_missing = match &error.kind { + NotifyErrorKind::PathNotFound => true, + NotifyErrorKind::Io(io_error) => io_error.kind() == std::io::ErrorKind::NotFound, + _ => false, + }; + is_missing + && error.paths.iter().all(|path| { + watched_targets + .iter() + .any(|target| path_is_transient_target(path, target)) + }) +} + +fn path_is_transient_target(path: &Path, target: &CompiledWatchTarget) -> bool { + let matches = |candidate: &Path| { + if target.recursive { + candidate != target.path && candidate.starts_with(&target.path) + } else { + candidate == target.path + } + }; + matches(path) + || private_path_variant(path).is_some_and(|candidate| matches(&candidate)) + || public_path_variant(path).is_some_and(|candidate| matches(&candidate)) +} + +fn is_unrelated_poll_error( + watcher_kind: WatcherKind, + watched_targets: &[CompiledWatchTarget], + error: ¬ify::Error, +) -> bool { + watcher_kind == WatcherKind::Poll + && !error.paths.is_empty() + && error.paths.iter().all(|path| { + watched_targets + .iter() + .all(|target| !paths_overlap(path, &target.path)) + }) +} + +fn paths_overlap(path: &Path, target: &Path) -> bool { + let overlaps = + |candidate: &Path| candidate.starts_with(target) || target.starts_with(candidate); + overlaps(path) + || private_path_variant(path).is_some_and(|candidate| overlaps(&candidate)) + || public_path_variant(path).is_some_and(|candidate| overlaps(&candidate)) +} + fn closest_existing_ancestor(path: &Path) -> Result { let mut candidate = path; loop { @@ -869,7 +955,7 @@ mod tests { } #[test] - fn resolve_watch_registration_keeps_existing_poll_file_exact() { + fn resolve_watch_registration_uses_parent_for_existing_poll_file() { let dir = tempdir().expect("tempdir"); let file = dir.path().join("watched.txt"); std::fs::write(&file, "hello\n").expect("write watched file"); @@ -886,12 +972,128 @@ mod tests { assert_eq!( registrations, vec![CompiledWatchTarget { - path: file, + path: dir.path().to_path_buf(), recursive: false, }] ); } + #[test] + fn missing_path_watcher_errors_are_transient() { + let target = CompiledWatchTarget { + path: PathBuf::from("/tmp/example/watched.txt"), + recursive: false, + }; + let io_error = notify::Error::io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file disappeared during scan", + )) + .add_path(target.path.clone()); + + assert!(is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&target), + &io_error + )); + assert!(is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&target), + ¬ify::Error::path_not_found().add_path(target.path.clone()) + )); + assert!(!is_transient_missing_path_error( + WatcherKind::Native, + std::slice::from_ref(&target), + &io_error + )); + } + + #[test] + fn pathless_and_watch_root_missing_errors_remain_fatal() { + let literal_target = CompiledWatchTarget { + path: PathBuf::from("/tmp/example/watched.txt"), + recursive: false, + }; + let recursive_target = CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content"), + recursive: true, + }; + + assert!(!is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&literal_target), + ¬ify::Error::path_not_found() + )); + assert!(!is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&literal_target), + ¬ify::Error::path_not_found().add_path(PathBuf::from("/tmp/example")) + )); + assert!(!is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&recursive_target), + ¬ify::Error::path_not_found().add_path(recursive_target.path.clone()) + )); + assert!(is_transient_missing_path_error( + WatcherKind::Poll, + std::slice::from_ref(&recursive_target), + ¬ify::Error::path_not_found().add_path(recursive_target.path.join("post.md")) + )); + } + + #[test] + fn permission_watcher_errors_remain_fatal() { + let error = notify::Error::io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + )); + + assert!(!is_transient_missing_path_error( + WatcherKind::Poll, + &[], + &error + )); + } + + #[test] + fn polling_errors_for_unrelated_siblings_are_non_fatal() { + let target = CompiledWatchTarget { + path: PathBuf::from("/tmp/example/watched.txt"), + recursive: false, + }; + let unrelated_error = notify::Error::io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + )) + .add_path(PathBuf::from("/tmp/example/unrelated.txt")); + + assert!(is_unrelated_poll_error( + WatcherKind::Poll, + &[target], + &unrelated_error + )); + } + + #[test] + fn polling_errors_overlapping_a_target_remain_fatal() { + let target = CompiledWatchTarget { + path: PathBuf::from("/tmp/example/watched.txt"), + recursive: false, + }; + for error_path in ["/tmp/example", "/tmp/example/watched.txt"] { + let error = notify::Error::io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied", + )) + .add_path(PathBuf::from(error_path)); + + assert!(!is_unrelated_poll_error( + WatcherKind::Poll, + std::slice::from_ref(&target), + &error + )); + } + } + #[test] fn resolve_watch_registration_keeps_existing_native_file_and_parent() { let dir = tempdir().expect("tempdir"); diff --git a/tests/watch_flake_smoke.rs b/tests/watch_flake_smoke.rs index e865b20..d40dcab 100644 --- a/tests/watch_flake_smoke.rs +++ b/tests/watch_flake_smoke.rs @@ -27,22 +27,50 @@ fn repeated_literal_file_edits_keep_triggering_native_watch_workflow() { } } +#[test] +fn deleting_and_recreating_a_polled_file_keeps_the_runtime_alive() { + let fixture = WatchFixture::new_with_watcher("poll"); + let mut child = DevloopChild::spawn(&fixture); + + child.wait_for_log_line("startup value: initial", Duration::from_secs(10)); + child.wait_for_log_line("watching ", Duration::from_secs(10)); + + fixture.remove_value(); + child.wait_for_log_line( + "workflow failed; continuing runtime in degraded mode", + Duration::from_secs(10), + ); + child.assert_running(); + + fixture.write_value("recreated"); + child.wait_for_log_line("changed value: recreated", Duration::from_secs(10)); + child.assert_running(); +} + struct WatchFixture { dir: TempDir, } impl WatchFixture { fn new() -> Self { + Self::new_with_watcher("native") + } + + fn new_with_watcher(watcher_kind: &str) -> Self { let dir = tempfile::tempdir().expect("create tempdir"); let fixture = Self { dir }; fixture.write("watched.txt", "initial\n"); fixture.write( "devloop.toml", - r#"root = "." + &r#"root = "." debounce_ms = 300 state_file = "./.devloop/state.json" startup_workflows = ["startup"] +[watcher] +kind = "__WATCHER_KIND__" +poll_interval_ms = 50 + [watch.content] paths = ["watched.txt"] workflow = "content" @@ -65,7 +93,8 @@ steps = [ { action = "run_hook", hook = "current_value" }, { action = "log", message = "changed value: {{current_value}}" }, ] -"#, +"# + .replace("__WATCHER_KIND__", watcher_kind), ); fixture } @@ -78,6 +107,11 @@ steps = [ self.write("watched.txt", &format!("{value}\n")); } + fn remove_value(&self) { + std::fs::remove_file(self.dir.path().join("watched.txt")) + .expect("remove watched fixture file"); + } + fn write(&self, relative_path: &str, contents: &str) { let path = self.dir.path().join(relative_path); if let Some(parent) = path.parent() { @@ -101,6 +135,7 @@ impl DevloopChild { .arg("--config") .arg(fixture.config_path()) .current_dir(fixture.dir.path()) + .env("RUST_LOG", "info") .stdout(Stdio::null()) .stderr(Stdio::piped()); let mut child = command.spawn().expect("spawn devloop"); @@ -149,6 +184,16 @@ impl DevloopChild { } } } + + fn assert_running(&mut self) { + assert!( + self.child + .try_wait() + .expect("query devloop status") + .is_none(), + "devloop exited after a watched file was deleted" + ); + } } impl Drop for DevloopChild {