From 8bb3b47d88dac06b9996e58c080f8614a7d16afc Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+pasunboneleve@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:12:46 +1000 Subject: [PATCH 1/3] Keep polling after watched files disappear Context: The polling backend registered literal files as scan roots. Deleting one made notify report a missing scan root on every poll, and the runtime propagated the first report as a fatal watcher error. Decision: Register the immediate parent for polled literal files, and treat only PathNotFound and IO NotFound watcher reports as transient. Keep other watcher errors fatal. Exercise deletion, continued supervision, recreation, and a later workflow in one process-level regression test. Alternatives considered: Ignoring every watcher error would keep the loop alive, but it would conceal permission failures and broken watcher backends. Ignoring NotFound without changing registration would also leave the deleted file as a noisy scan root. Tradeoffs: Polling a parent can inspect unrelated siblings, but event classification still runs workflows only for configured paths. The parent registration removes the permanent missing-root condition and preserves recreation events. Architectural impact: The watcher adapter now owns recovery for transient filesystem disappearance; the runtime still fails on non-transient watcher faults. This is a SemVer patch fix recorded under Unreleased; this commit does not bump the package version or create a dated release section. --- CHANGELOG.md | 5 ++++ docs/behavior.md | 3 ++ src/engine.rs | 61 +++++++++++++++++++++++++++++++++----- tests/watch_flake_smoke.rs | 49 ++++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa00c7a..1b7dce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +### 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/docs/behavior.md b/docs/behavior.md index 2c01e98..915dfdf 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -69,6 +69,9 @@ 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 file that disappears during a + scan is treated as a transient condition; other watcher errors remain fatal. 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..b23b980 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,19 @@ 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(&error) => { + warn!( + error = %error, + "watched path disappeared during a filesystem scan; continuing" + ); + } + Some(Err(error)) => return Err(error.into()), None => return Err(anyhow!("watcher event channel disconnected")), } } @@ -645,7 +652,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 +682,14 @@ fn resolve_watch_registrations( }]) } +fn is_transient_missing_path_error(error: ¬ify::Error) -> bool { + match &error.kind { + NotifyErrorKind::PathNotFound => true, + NotifyErrorKind::Io(io_error) => io_error.kind() == std::io::ErrorKind::NotFound, + _ => false, + } +} + fn closest_existing_ancestor(path: &Path) -> Result { let mut candidate = path; loop { @@ -869,7 +893,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 +910,35 @@ 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 io_error = notify::Error::io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file disappeared during scan", + )); + + assert!(is_transient_missing_path_error(&io_error)); + assert!(is_transient_missing_path_error( + ¬ify::Error::path_not_found() + )); + } + + #[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(&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 { From 50135658e87f77a6258b12accadc120bcc9dcac1 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+pasunboneleve@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:16:31 +1000 Subject: [PATCH 2/3] Narrow polling recovery and prepare 0.10.5 Context: Parent-directory polling keeps deleted literal files observable, but a broad missing-path exception could hide permanent native-watch loss, and scanning a parent could make an unrelated sibling's failure fatal. Decision: Limit transient missing-path recovery to the polling backend. Ignore a polling error only when every reported path is disjoint from configured watch targets; keep target, parent, pathless, and native watcher errors fatal. Alternatives considered: Treating every NotFound report as recoverable was simpler but unsafe for native registrations. Treating every parent-scan error as fatal coupled configured files to unrelated siblings introduced by the adapter's broader scan root. Tradeoffs: The overlap test depends on notify supplying paths for sibling-specific errors. Pathless errors remain fatal because devloop cannot prove that they are safe. Architectural impact: Recovery now follows the watcher backend and configured-target boundary. The runtime remains strict whenever the adapter cannot demonstrate that an error is both polling-specific and outside the user's watch surface. Changelog release mode moves the fix into 0.10.5 and aligns Cargo.toml, Cargo.lock, release-note input, and intended tag v0.10.5. --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/behavior.md | 4 ++- src/engine.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7dce2..043e304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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 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 915dfdf..d87c832 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -71,7 +71,9 @@ watch-group patterns and watches only those files or directories. - 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 file that disappears during a - scan is treated as a transient condition; other watcher errors remain fatal. + scan is treated as a transient condition. Errors confined to unconfigured + siblings do not widen the watch group's failure boundary; errors overlapping + a configured target remain fatal. 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 b23b980..fe42c6d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -176,12 +176,25 @@ impl Engine { watch_deadline = Some(Instant::now() + self.config.debounce()); } } - Some(Err(error)) if is_transient_missing_path_error(&error) => { + Some(Err(error)) if is_transient_missing_path_error( + self.config.watcher.kind, + &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")), } @@ -682,7 +695,10 @@ fn resolve_watch_registrations( }]) } -fn is_transient_missing_path_error(error: ¬ify::Error) -> bool { +fn is_transient_missing_path_error(watcher_kind: WatcherKind, error: ¬ify::Error) -> bool { + if watcher_kind != WatcherKind::Poll { + return false; + } match &error.kind { NotifyErrorKind::PathNotFound => true, NotifyErrorKind::Io(io_error) => io_error.kind() == std::io::ErrorKind::NotFound, @@ -690,6 +706,28 @@ fn is_transient_missing_path_error(error: ¬ify::Error) -> bool { } } +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 { @@ -923,10 +961,18 @@ mod tests { "file disappeared during scan", )); - assert!(is_transient_missing_path_error(&io_error)); assert!(is_transient_missing_path_error( + WatcherKind::Poll, + &io_error + )); + assert!(is_transient_missing_path_error( + WatcherKind::Poll, ¬ify::Error::path_not_found() )); + assert!(!is_transient_missing_path_error( + WatcherKind::Native, + &io_error + )); } #[test] @@ -936,7 +982,47 @@ mod tests { "permission denied", )); - assert!(!is_transient_missing_path_error(&error)); + 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] From 8f25b44c51deadbdaa2f74b1ec1e8cc211eb9afc Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+pasunboneleve@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:21:34 +1000 Subject: [PATCH 3/3] Keep missing watch roots fatal Context: The polling recovery guard accepted every NotFound report before checking its path. A pathless error or missing registered parent could therefore leave the runtime alive without an effective watcher. Decision: Recover only when notify reports configured literal files or disappearing children beneath recursive targets. Keep pathless reports, recursive target roots, parent registration roots, and native watcher errors fatal. Alternatives considered: Applying the existing overlap test after the NotFound guard would still treat a configured recursive root as transient. The dedicated target-shape predicate distinguishes recoverable children from registration roots explicitly. Tradeoffs: Recovery requires notify to report the affected path. Pathless errors fail the session because devloop cannot prove that observation can continue safely. Architectural impact: The polling adapter's recovery contract now matches its documented boundary: leaf disappearance is recoverable, while loss of the observation root is not. The 0.10.5 version and dated changelog remain consistent. --- docs/behavior.md | 8 ++--- src/engine.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/docs/behavior.md b/docs/behavior.md index d87c832..3aff514 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -70,10 +70,10 @@ watch-group patterns and watches only those files or directories. 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 file that disappears during a - scan is treated as a transient condition. Errors confined to unconfigured - siblings do not widen the watch group's failure boundary; errors overlapping - a configured target remain fatal. + 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 fe42c6d..52738e7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -178,6 +178,7 @@ impl Engine { } Some(Err(error)) if is_transient_missing_path_error( self.config.watcher.kind, + &adapter.watched_targets, &error, ) => { warn!( @@ -695,15 +696,38 @@ fn resolve_watch_registrations( }]) } -fn is_transient_missing_path_error(watcher_kind: WatcherKind, error: ¬ify::Error) -> bool { - if watcher_kind != WatcherKind::Poll { +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; } - match &error.kind { + 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( @@ -956,25 +980,66 @@ mod tests { #[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, - ¬ify::Error::path_not_found() + 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( @@ -982,7 +1047,11 @@ mod tests { "permission denied", )); - assert!(!is_transient_missing_path_error(WatcherKind::Poll, &error)); + assert!(!is_transient_missing_path_error( + WatcherKind::Poll, + &[], + &error + )); } #[test]