From 8cc2141d9507657ccb3f01de527bcf6a461dd38e Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:23:23 +1000 Subject: [PATCH 1/8] Add watch flake smoke test scaffolding --- AGENTS.md | 3 + CHANGELOG.md | 13 +++ Cargo.lock | 39 +++++++ Cargo.toml | 3 + docs/behavior.md | 6 +- docs/configuration.md | 19 ++++ examples/blog/devloop.toml | 3 + src/browser_reload.rs | 1 + src/config.rs | 226 ++++++++++++++++++++++++++++++++++++- src/core.rs | 1 + src/engine.rs | 72 +++++++++--- src/external_events.rs | 1 + tests/watch_flake_smoke.rs | 178 +++++++++++++++++++++++++++++ 13 files changed, 549 insertions(+), 16 deletions(-) create mode 100644 tests/watch_flake_smoke.rs diff --git a/AGENTS.md b/AGENTS.md index aba1b79..cc2cd32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ without hard-coding knowledge of any one repository. avoid baking it into the core. - Do not push unless explicitly asked. This repository may not even have a remote during early development. +- Do not use `sleep` to resolve races. Races must be resolved with + deterministic logic, explicit readiness signals, or ordered state + transitions. - Run quality gates for code changes: `cargo fmt`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7397310..6b65be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +### Added +- Added a configurable watcher backend with non-breaking `native` + default behavior plus a `poll` fallback mode for environments where + native filesystem notifications are unreliable. +- Added a Rust repeated-edit watch flake smoke test that runs under + `cargo test` and therefore in GitHub Actions alongside the existing + runtime smoke test. + +### Changed +- `devloop` now derives concrete watch targets from configured watch + patterns and asks the backend to watch only those files or + directories instead of always watching the whole repository root. + ## [0.7.0] - 2026-03-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index ba8d79d..8af0ae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tempfile", "tokio", "tokio-stream", "toml", @@ -283,6 +284,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -742,6 +749,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1098,6 +1111,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1325,6 +1351,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Cargo.toml b/Cargo.toml index ee8fea7..7118abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,6 @@ toml = "0.8.22" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/docs/behavior.md b/docs/behavior.md index cc0857d..82a6971 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -49,13 +49,17 @@ merged back into the live session. ## Watching and debounce -`devloop` watches the configured `root` recursively. +`devloop` derives concrete filesystem watch targets from the configured +watch-group patterns and watches only those files or directories. - Only relevant file-system events are considered. - Events are batched for `debounce_ms`. - Matching changes are grouped by workflow name before execution. - Each workflow receives the set of changed relative paths that matched it during the debounce window. +- The default backend uses native filesystem notifications. A polling + backend can be selected in config as a fallback for environments + where native events are unreliable. If multiple watch groups map to the same workflow, their matched paths are merged for that workflow run. diff --git a/docs/configuration.md b/docs/configuration.md index 9e0b1c3..0afc04e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,21 @@ startup_workflows = ["startup"] - `startup_workflows`: workflows to run after autostart processes have been started. +Optional watcher backend config: + +```toml +[watcher] +kind = "native" +poll_interval_ms = 250 +``` + +- `watcher.kind`: watcher backend to use. `native` is the default and + uses the platform-recommended `notify` backend. `poll` uses + `notify`'s polling watcher as a fallback for environments where + native filesystem events are unreliable. +- `watcher.poll_interval_ms`: polling interval used when + `watcher.kind = "poll"`. Default: `250`. + Optional browser reload server config: ```toml @@ -51,6 +66,10 @@ workflow = "rust" - `workflow`: workflow to run when a matching file changes. If omitted, the watch-group name is used as the workflow name. +`devloop` derives concrete watch targets from these patterns and asks +the backend to watch only those literal files or directories instead of +always watching the whole repository root recursively. + ## Processes Processes are long-running commands supervised by `devloop`. diff --git a/examples/blog/devloop.toml b/examples/blog/devloop.toml index 23a615a..546af6c 100644 --- a/examples/blog/devloop.toml +++ b/examples/blog/devloop.toml @@ -6,6 +6,9 @@ debounce_ms = 300 state_file = "./.devloop/state.json" startup_workflows = ["startup"] +[watcher] +kind = "native" + [watch.rust] paths = ["src/**/*.rs", "Cargo.toml", "content/layout.html", "content/banner.html", "content/site.toml"] workflow = "rust" diff --git a/src/browser_reload.rs b/src/browser_reload.rs index 35364f6..1aa6202 100644 --- a/src/browser_reload.rs +++ b/src/browser_reload.rs @@ -135,6 +135,7 @@ mod tests { Config { root: ".".into(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some("./state.json".into()), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/config.rs b/src/config.rs index f8e47e7..b508778 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,8 @@ pub struct Config { pub root: PathBuf, #[serde(default = "default_debounce_ms")] pub debounce_ms: u64, + #[serde(default)] + pub watcher: WatcherConfig, pub state_file: Option, #[serde(default)] pub startup_workflows: Vec, @@ -92,6 +94,7 @@ impl Config { } self.event_server.validate()?; self.browser_reload_server.validate()?; + self.watcher.validate()?; for (name, event) in &self.event { event .validate() @@ -129,6 +132,27 @@ impl Config { .collect() } + pub fn compiled_watch_targets(&self) -> Vec { + self.compiled_watch_targets_for(self.watcher.kind) + } + + pub fn compiled_watch_targets_for( + &self, + watcher_kind: WatcherKind, + ) -> Vec { + let mut targets = BTreeMap::::new(); + for group in self.watch.values() { + for pattern in &group.paths { + let target = CompiledWatchTarget::from_pattern(&self.root, pattern, watcher_kind); + targets + .entry(target.path) + .and_modify(|recursive| *recursive |= target.recursive) + .or_insert(target.recursive); + } + } + targets.into_iter().map(CompiledWatchTarget::from).collect() + } + pub fn has_external_events(&self) -> bool { !self.event.is_empty() } @@ -213,6 +237,118 @@ impl CompiledWatchGroup { } } +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WatcherKind { + #[default] + Native, + Poll, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WatcherConfig { + #[serde(default)] + pub kind: WatcherKind, + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u64, +} + +impl Default for WatcherConfig { + fn default() -> Self { + Self { + kind: WatcherKind::Native, + poll_interval_ms: default_poll_interval_ms(), + } + } +} + +impl WatcherConfig { + fn validate(&self) -> Result<()> { + if self.poll_interval_ms == 0 { + return Err(anyhow!( + "watcher poll_interval_ms must be greater than zero" + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CompiledWatchTarget { + pub path: PathBuf, + pub recursive: bool, +} + +impl From<(PathBuf, bool)> for CompiledWatchTarget { + fn from((path, recursive): (PathBuf, bool)) -> Self { + Self { path, recursive } + } +} + +impl CompiledWatchTarget { + fn from_pattern(root: &Path, pattern: &str, watcher_kind: WatcherKind) -> Self { + let mut prefix = PathBuf::new(); + let mut recursive = false; + let mut saw_dynamic_segment = false; + for segment in pattern.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == "**" { + recursive = true; + break; + } + if segment_has_glob_magic(segment) { + recursive = true; + saw_dynamic_segment = true; + break; + } + prefix.push(segment); + } + + if prefix.as_os_str().is_empty() { + return Self { + path: root.to_path_buf(), + recursive: true, + }; + } + + if pattern_is_literal(pattern) { + let candidate = root.join(&prefix); + if candidate.is_dir() { + return Self { + path: candidate, + recursive: true, + }; + } + + return Self { + path: match watcher_kind { + WatcherKind::Native => candidate + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()), + WatcherKind::Poll => candidate, + }, + recursive: false, + }; + } + + Self { + path: root.join(prefix), + recursive: recursive || !saw_dynamic_segment && !pattern_is_literal(pattern), + } + } +} + +fn pattern_is_literal(pattern: &str) -> bool { + !pattern.split('/').any(segment_has_glob_magic) && !pattern.contains("**") +} + +fn segment_has_glob_magic(segment: &str) -> bool { + segment.contains('*') || segment.contains('?') || segment.contains('[') || segment.contains('{') +} + #[derive(Debug, Clone, Deserialize)] pub struct ProcessSpec { pub command: Vec, @@ -702,11 +838,11 @@ pub enum LogStyle { } pub fn absolutize(base: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { + normalize_path_buf(if path.is_absolute() { path.to_path_buf() } else { base.join(path) - } + }) } fn default_debounce_ms() -> u64 { @@ -721,6 +857,10 @@ fn default_timeout_ms() -> u64 { 15_000 } +fn default_poll_interval_ms() -> u64 { + 250 +} + fn default_true() -> bool { true } @@ -752,6 +892,10 @@ fn default_browser_reload_server_bind() -> String { "127.0.0.1:0".to_string() } +fn normalize_path_buf(path: PathBuf) -> PathBuf { + path.components().collect() +} + #[cfg(test)] mod tests { use super::*; @@ -760,6 +904,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1186,6 +1331,83 @@ mod tests { assert_eq!(config.bind, "127.0.0.1:0"); } + #[test] + fn watcher_defaults_to_native_with_poll_fallback_interval() { + let watcher: WatcherConfig = toml::from_str("").expect("parse watcher config"); + + assert_eq!(watcher.kind, WatcherKind::Native); + assert_eq!(watcher.poll_interval_ms, 250); + } + + #[test] + fn compiled_watch_targets_reduce_patterns_to_concrete_paths() { + let mut config = base_config(); + config.root = PathBuf::from("/tmp/example"); + config.watch.insert( + "rust".into(), + WatchGroup { + paths: vec![ + "src/**/*.rs".into(), + "Cargo.toml".into(), + "content/banner.html".into(), + ], + workflow: Some("rust".into()), + }, + ); + config.watch.insert( + "content".into(), + WatchGroup { + paths: vec!["content/**/*.md".into(), "content/**/*.html".into()], + workflow: Some("content".into()), + }, + ); + + assert_eq!( + config.compiled_watch_targets(), + vec![ + CompiledWatchTarget { + path: PathBuf::from("/tmp/example"), + recursive: false, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content"), + recursive: true, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/src"), + recursive: true, + }, + ] + ); + } + + #[test] + fn poll_watch_targets_keep_exact_files_for_literal_patterns() { + let mut config = base_config(); + config.root = PathBuf::from("/tmp/example"); + config.watch.insert( + "content".into(), + WatchGroup { + paths: vec!["watched.txt".into(), "content/banner.html".into()], + workflow: Some("content".into()), + }, + ); + + assert_eq!( + config.compiled_watch_targets_for(WatcherKind::Poll), + vec![ + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content/banner.html"), + recursive: false, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/watched.txt"), + recursive: false, + }, + ] + ); + } + #[test] fn config_detects_notify_reload_in_nested_workflow() { let mut config = base_config(); diff --git a/src/core.rs b/src/core.rs index cdac1a0..24340fc 100644 --- a/src/core.rs +++ b/src/core.rs @@ -612,6 +612,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/engine.rs b/src/engine.rs index 547e5e7..fa1c250 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -7,7 +7,8 @@ use std::time::Duration; use anyhow::{Result, anyhow}; use notify::{ - Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, + Config as NotifyConfig, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, + Watcher, event::{AccessKind, AccessMode}, }; use serde_json::{Map, Value}; @@ -17,7 +18,7 @@ use tracing::{error, info}; use unicode_width::UnicodeWidthStr; use crate::browser_reload::{BrowserReloadSender, BrowserReloadServer, notify_browser_reload}; -use crate::config::{CompiledWatchGroup, Config, LogStyle}; +use crate::config::{CompiledWatchGroup, CompiledWatchTarget, Config, LogStyle, WatcherKind}; use crate::core::{RuntimeEffect, RuntimeEvent, RuntimeMachine, WorkflowEffect, WorkflowMachine}; use crate::external_events::{ExternalEventMessage, ExternalEventServer}; use crate::processes::ProcessManager; @@ -69,8 +70,9 @@ struct LiveRuntimeAdapter<'a, 'b> { config: &'a Config, processes: &'a mut ProcessManager<'b>, state: &'a SessionState, - watcher: &'a mut RecommendedWatcher, + watcher: &'a mut Box, watcher_shutdown: Arc, + watched_targets: Vec, external_event_tx: tokio::sync::mpsc::UnboundedSender, external_event_server: Option, browser_reload_server: Option, @@ -90,17 +92,15 @@ impl Engine { )?; let mut processes = ProcessManager::new(&self.config); let watch_groups = self.config.compiled_watchers()?; + let watched_targets = self.config.compiled_watch_targets(); let (tx, rx) = mpsc::channel(); let (external_event_tx, mut external_event_rx) = tokio::sync::mpsc::unbounded_channel(); let tx_watcher = tx.clone(); let watcher_shutdown = Arc::new(AtomicBool::new(false)); let watcher_shutdown_callback = watcher_shutdown.clone(); - let mut watcher = RecommendedWatcher::new( - move |result| { - forward_watcher_event(&tx_watcher, &watcher_shutdown_callback, result); - }, - NotifyConfig::default(), - )?; + let mut watcher = create_watcher(&self.config, move |result| { + forward_watcher_event(&tx_watcher, &watcher_shutdown_callback, result); + })?; let mut maintain_tick = tokio::time::interval(Duration::from_secs(1)); let mut runtime = RuntimeMachine::new(&self.config); let runtime_start = Instant::now(); @@ -114,6 +114,7 @@ impl Engine { state: &state, watcher: &mut watcher, watcher_shutdown, + watched_targets, external_event_tx, external_event_server: None, browser_reload_server: None, @@ -273,9 +274,21 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { } async fn start_watching(&mut self) -> Result<()> { - self.watcher - .watch(&self.config.root, RecursiveMode::Recursive)?; - info!("watching {}", self.config.root.display()); + for target in &self.watched_targets { + self.watcher.watch( + &target.path, + if target.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + )?; + info!( + "watching {}{}", + target.path.display(), + if target.recursive { " (recursive)" } else { "" } + ); + } Ok(()) } @@ -296,7 +309,9 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { async fn stop_watching(&mut self) -> Result<()> { self.watcher_shutdown.store(true, Ordering::Relaxed); - self.watcher.unwatch(&self.config.root)?; + for target in &self.watched_targets { + self.watcher.unwatch(&target.path)?; + } Ok(()) } @@ -305,6 +320,24 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { } } +fn create_watcher(config: &Config, handler: F) -> Result> +where + F: FnMut(notify::Result) + Send + 'static, +{ + let notify_config = NotifyConfig::default(); + match config.watcher.kind { + WatcherKind::Native => { + Ok(Box::new(RecommendedWatcher::new(handler, notify_config)?) as Box<_>) + } + WatcherKind::Poll => Ok(Box::new(PollWatcher::new( + handler, + notify_config + .with_compare_contents(true) + .with_poll_interval(Duration::from_millis(config.watcher.poll_interval_ms)), + )?) as Box<_>), + } +} + async fn execute_runtime_effects( runtime: &mut RuntimeMachine, adapter: &mut A, @@ -683,6 +716,7 @@ mod tests { let mut config = Config { root: root.clone(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -738,6 +772,7 @@ mod tests { let mut config = Config { root: root.clone(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -806,6 +841,7 @@ mod tests { let mut config = Config { root, debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -939,6 +975,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -996,6 +1033,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1046,6 +1084,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1123,6 +1162,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1182,6 +1222,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1321,6 +1362,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1422,6 +1464,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1487,6 +1530,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1538,6 +1582,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec!["startup".into()], watch: BTreeMap::new(), @@ -1572,6 +1617,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/external_events.rs b/src/external_events.rs index 86dbf5e..5b8f54e 100644 --- a/src/external_events.rs +++ b/src/external_events.rs @@ -240,6 +240,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/tests/watch_flake_smoke.rs b/tests/watch_flake_smoke.rs new file mode 100644 index 0000000..4890902 --- /dev/null +++ b/tests/watch_flake_smoke.rs @@ -0,0 +1,178 @@ +use std::fs; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tempfile::TempDir; + +#[test] +fn repeated_literal_file_edits_keep_triggering_native_watch_workflow() { + let fixture = WatchFixture::new(); + 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)); + + for write_index in 1..=10 { + let value = format!("native-trial-{}", "x".repeat(write_index)); + fixture.write_value(&value); + fixture.wait_for_state_value(&value, Duration::from_secs(10)); + } +} + +struct WatchFixture { + dir: TempDir, +} + +impl WatchFixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("create tempdir"); + let fixture = Self { dir }; + fixture.write("watched.txt", "initial\n"); + fixture.write( + "devloop.toml", + r#"root = "." +debounce_ms = 300 +state_file = "./.devloop/state.json" +startup_workflows = ["startup"] + +[watch.content] +paths = ["watched.txt"] +workflow = "content" + +[hook.current_value] +command = ["sed", "-n", "1p", "watched.txt"] +cwd = "." +capture = "text" +state_key = "current_value" +output = { inherit = false } + +[workflow.startup] +steps = [ + { action = "run_hook", hook = "current_value" }, + { action = "log", message = "startup value: {{current_value}}" }, +] + +[workflow.content] +steps = [ + { action = "run_hook", hook = "current_value" }, + { action = "log", message = "changed value: {{current_value}}" }, +] +"#, + ); + fixture + } + + fn config_path(&self) -> std::path::PathBuf { + self.dir.path().join("devloop.toml") + } + + fn state_path(&self) -> std::path::PathBuf { + self.dir.path().join(".devloop/state.json") + } + + fn write_value(&self, value: &str) { + self.write("watched.txt", &format!("{value}\n")); + } + + fn wait_for_state_value(&self, value: &str, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if let Ok(raw) = fs::read_to_string(self.state_path()) + && let Ok(json) = serde_json::from_str::(&raw) + && json + .get("current_value") + .and_then(Value::as_str) + .is_some_and(|current| current == value) + { + return; + } + + if Instant::now() > deadline { + let current = fs::read_to_string(self.state_path()) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|json| { + json.get("current_value") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }); + panic!( + "timed out waiting for current_value '{value}', last observed value: {:?}", + current + ); + } + + std::hint::spin_loop(); + } + } + + fn write(&self, relative_path: &str, contents: &str) { + let path = self.dir.path().join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create fixture parent directories"); + } + fs::write(path, contents).expect("write fixture file"); + } +} + +struct DevloopChild { + child: Child, + lines: Receiver, +} + +impl DevloopChild { + fn spawn(fixture: &WatchFixture) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_devloop")); + command + .arg("run") + .arg("--config") + .arg(fixture.config_path()) + .current_dir(fixture.dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn devloop"); + let stderr = child.stderr.take().expect("take child stderr"); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(line) => { + if tx.send(line).is_err() { + return; + } + } + Err(_) => return, + } + } + }); + Self { child, lines: rx } + } + + fn wait_for_log_line(&mut self, needle: &str, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| Duration::from_secs(0)); + let line = self + .lines + .recv_timeout(remaining) + .unwrap_or_else(|_| panic!("timed out waiting for log line containing '{needle}'")); + if line.contains(needle) { + return; + } + } + } +} + +impl Drop for DevloopChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} From 54e5c38823f59ed49937d675be5f7b26ceda2c67 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:46:30 +1000 Subject: [PATCH 2/8] Fix watch batch cancellation flake --- CHANGELOG.md | 5 + src/config.rs | 60 ++------- src/engine.rs | 244 +++++++++++++++++++++++++++++++------ tests/watch_flake_smoke.rs | 68 ++++------- 4 files changed, 239 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b65be0..c5533cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ All notable changes to `devloop` will be recorded in this file. patterns and asks the backend to watch only those files or directories instead of always watching the whole repository root. +### Fixed +- Native watch registration now resolves file and directory targets at + runtime, so startup no longer depends on those paths already existing + when config is parsed. + ## [0.7.0] - 2026-03-27 ### Added diff --git a/src/config.rs b/src/config.rs index b508778..a1caedc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,17 +133,10 @@ impl Config { } pub fn compiled_watch_targets(&self) -> Vec { - self.compiled_watch_targets_for(self.watcher.kind) - } - - pub fn compiled_watch_targets_for( - &self, - watcher_kind: WatcherKind, - ) -> Vec { let mut targets = BTreeMap::::new(); for group in self.watch.values() { for pattern in &group.paths { - let target = CompiledWatchTarget::from_pattern(&self.root, pattern, watcher_kind); + let target = CompiledWatchTarget::from_pattern(&self.root, pattern); targets .entry(target.path) .and_modify(|recursive| *recursive |= target.recursive) @@ -286,10 +279,9 @@ impl From<(PathBuf, bool)> for CompiledWatchTarget { } impl CompiledWatchTarget { - fn from_pattern(root: &Path, pattern: &str, watcher_kind: WatcherKind) -> Self { + fn from_pattern(root: &Path, pattern: &str) -> Self { let mut prefix = PathBuf::new(); let mut recursive = false; - let mut saw_dynamic_segment = false; for segment in pattern.split('/') { if segment.is_empty() || segment == "." { continue; @@ -300,7 +292,6 @@ impl CompiledWatchTarget { } if segment_has_glob_magic(segment) { recursive = true; - saw_dynamic_segment = true; break; } prefix.push(segment); @@ -314,29 +305,15 @@ impl CompiledWatchTarget { } if pattern_is_literal(pattern) { - let candidate = root.join(&prefix); - if candidate.is_dir() { - return Self { - path: candidate, - recursive: true, - }; - } - return Self { - path: match watcher_kind { - WatcherKind::Native => candidate - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| root.to_path_buf()), - WatcherKind::Poll => candidate, - }, + path: root.join(prefix), recursive: false, }; } Self { path: root.join(prefix), - recursive: recursive || !saw_dynamic_segment && !pattern_is_literal(pattern), + recursive, } } } @@ -1366,43 +1343,20 @@ mod tests { config.compiled_watch_targets(), vec![ CompiledWatchTarget { - path: PathBuf::from("/tmp/example"), + path: PathBuf::from("/tmp/example/Cargo.toml"), recursive: false, }, CompiledWatchTarget { path: PathBuf::from("/tmp/example/content"), recursive: true, }, - CompiledWatchTarget { - path: PathBuf::from("/tmp/example/src"), - recursive: true, - }, - ] - ); - } - - #[test] - fn poll_watch_targets_keep_exact_files_for_literal_patterns() { - let mut config = base_config(); - config.root = PathBuf::from("/tmp/example"); - config.watch.insert( - "content".into(), - WatchGroup { - paths: vec!["watched.txt".into(), "content/banner.html".into()], - workflow: Some("content".into()), - }, - ); - - assert_eq!( - config.compiled_watch_targets_for(WatcherKind::Poll), - vec![ CompiledWatchTarget { path: PathBuf::from("/tmp/example/content/banner.html"), recursive: false, }, CompiledWatchTarget { - path: PathBuf::from("/tmp/example/watched.txt"), - recursive: false, + path: PathBuf::from("/tmp/example/src"), + recursive: true, }, ] ); diff --git a/src/engine.rs b/src/engine.rs index fa1c250..c99b9f4 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; use std::time::Duration; use anyhow::{Result, anyhow}; @@ -73,6 +72,7 @@ struct LiveRuntimeAdapter<'a, 'b> { watcher: &'a mut Box, watcher_shutdown: Arc, watched_targets: Vec, + active_watch_targets: Vec, external_event_tx: tokio::sync::mpsc::UnboundedSender, external_event_server: Option, browser_reload_server: Option, @@ -93,7 +93,7 @@ impl Engine { let mut processes = ProcessManager::new(&self.config); let watch_groups = self.config.compiled_watchers()?; let watched_targets = self.config.compiled_watch_targets(); - let (tx, rx) = mpsc::channel(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let (external_event_tx, mut external_event_rx) = tokio::sync::mpsc::unbounded_channel(); let tx_watcher = tx.clone(); let watcher_shutdown = Arc::new(AtomicBool::new(false)); @@ -115,11 +115,14 @@ impl Engine { watcher: &mut watcher, watcher_shutdown, watched_targets, + active_watch_targets: Vec::new(), external_event_tx, external_event_server: None, browser_reload_server: None, }; execute_runtime_effects(&mut runtime, &mut adapter).await?; + let mut pending_watch_events = Vec::new(); + let mut watch_deadline = None; loop { tokio::select! { @@ -139,9 +142,26 @@ impl Engine { return Ok(()); } } - batch = next_batch(&rx, self.config.debounce()) => { - let events = batch?; - let workflows = classify_events(&self.config.root, &watch_groups, &events); + event = rx.recv() => { + match event { + Some(result) => { + pending_watch_events.push(result?); + if watch_deadline.is_none() { + watch_deadline = Some(Instant::now() + self.config.debounce()); + } + } + None => return Err(anyhow!("watcher event channel disconnected")), + } + } + _ = async { + if let Some(deadline) = watch_deadline { + tokio::time::sleep_until(deadline).await; + } + }, if watch_deadline.is_some() => { + let workflows = + classify_events(&self.config.root, &watch_groups, &pending_watch_events); + pending_watch_events.clear(); + watch_deadline = None; if !workflows.is_empty() { runtime.handle_event(RuntimeEvent::WatchChanges { workflows }); if execute_runtime_effects(&mut runtime, &mut adapter).await? { @@ -274,19 +294,36 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { } async fn start_watching(&mut self) -> Result<()> { + self.active_watch_targets.clear(); + let mut registrations = BTreeMap::::new(); for target in &self.watched_targets { + for registration in resolve_watch_registrations(target, self.config.watcher.kind)? { + registrations + .entry(registration.path) + .and_modify(|recursive| *recursive |= registration.recursive) + .or_insert(registration.recursive); + } + } + + for (path, recursive) in registrations { + let registration = CompiledWatchTarget { path, recursive }; self.watcher.watch( - &target.path, - if target.recursive { + ®istration.path, + if registration.recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }, )?; + self.active_watch_targets.push(registration.clone()); info!( "watching {}{}", - target.path.display(), - if target.recursive { " (recursive)" } else { "" } + registration.path.display(), + if registration.recursive { + " (recursive)" + } else { + "" + } ); } Ok(()) @@ -309,9 +346,10 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { async fn stop_watching(&mut self) -> Result<()> { self.watcher_shutdown.store(true, Ordering::Relaxed); - for target in &self.watched_targets { + for target in &self.active_watch_targets { self.watcher.unwatch(&target.path)?; } + self.active_watch_targets.clear(); Ok(()) } @@ -385,7 +423,7 @@ async fn execute_runtime_effects( } fn forward_watcher_event( - tx: &mpsc::Sender>, + tx: &tokio::sync::mpsc::UnboundedSender>, shutting_down: &AtomicBool, result: notify::Result, ) { @@ -495,24 +533,67 @@ fn boxed_banner_lines(message: &str) -> [String; 3] { [border.clone(), line, border] } -async fn next_batch( - rx: &mpsc::Receiver>, - debounce: Duration, -) -> Result> { - let first = match rx.recv() { - Ok(result) => result?, - Err(_) => return Err(anyhow!("watcher event channel disconnected")), - }; - let start = Instant::now(); - let mut events = vec![first]; - while start.elapsed() < debounce { - match rx.try_recv() { - Ok(result) => events.push(result?), - Err(mpsc::TryRecvError::Empty) => sleep(Duration::from_millis(25)).await, - Err(mpsc::TryRecvError::Disconnected) => break, +fn resolve_watch_registrations( + target: &CompiledWatchTarget, + watcher_kind: WatcherKind, +) -> Result> { + if target.recursive { + return Ok(vec![CompiledWatchTarget { + path: closest_existing_ancestor(&target.path)?, + recursive: true, + }]); + } + + if target.path.exists() { + if target.path.is_dir() { + return Ok(vec![CompiledWatchTarget { + path: target.path.clone(), + recursive: true, + }]); + } + + return Ok(match watcher_kind { + WatcherKind::Native => { + let mut registrations = vec![target.clone()]; + if let Some(parent) = target.path.parent() { + registrations.push(CompiledWatchTarget { + path: parent.to_path_buf(), + recursive: false, + }); + } + registrations + } + WatcherKind::Poll => vec![target.clone()], + }); + } + + let immediate_parent = target + .path + .parent() + .ok_or_else(|| anyhow!("watch target '{}' has no parent", target.path.display()))?; + if immediate_parent.exists() { + return Ok(vec![CompiledWatchTarget { + path: immediate_parent.to_path_buf(), + recursive: false, + }]); + } + + Ok(vec![CompiledWatchTarget { + path: closest_existing_ancestor(immediate_parent)?, + recursive: true, + }]) +} + +fn closest_existing_ancestor(path: &Path) -> Result { + let mut candidate = path; + loop { + if candidate.exists() { + return Ok(candidate.to_path_buf()); } + candidate = candidate + .parent() + .ok_or_else(|| anyhow!("watch target '{}' has no existing ancestor", path.display()))?; } - Ok(events) } fn classify_events( @@ -587,6 +668,7 @@ mod tests { use std::collections::VecDeque; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; + use tempfile::tempdir; fn unique_state_path() -> PathBuf { let unique = SystemTime::now() @@ -620,19 +702,103 @@ mod tests { assert_eq!(grouped["content"], vec!["content/posts/example.md"]); } - #[tokio::test] - async fn next_batch_errors_when_watcher_channel_disconnects() { - let (_tx, rx) = mpsc::channel(); - drop(_tx); + #[test] + fn resolve_watch_registration_keeps_existing_poll_file_exact() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + std::fs::write(&file, "hello\n").expect("write watched file"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file.clone(), + recursive: false, + }, + WatcherKind::Poll, + ) + .expect("resolve watch registration"); - let error = next_batch(&rx, Duration::from_millis(10)) - .await - .expect_err("channel disconnect should error"); + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: file, + recursive: false, + }] + ); + } - assert!( - error - .to_string() - .contains("watcher event channel disconnected") + #[test] + fn resolve_watch_registration_keeps_existing_native_file_and_parent() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + std::fs::write(&file, "hello\n").expect("write watched file"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file.clone(), + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![ + CompiledWatchTarget { + path: file, + recursive: false, + }, + CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: false, + }, + ] + ); + } + + #[test] + fn resolve_watch_registration_falls_back_to_existing_parent_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file, + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: false, + }] + ); + } + + #[test] + fn resolve_watch_registration_climbs_recursively_when_parent_is_missing() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("nested").join("watched.txt"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: target, + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: true, + }] ); } @@ -1444,7 +1610,7 @@ mod tests { #[test] fn forward_watcher_event_ignores_send_failures_after_shutdown() { - let (tx, rx) = mpsc::channel(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let shutdown = AtomicBool::new(true); drop(rx); diff --git a/tests/watch_flake_smoke.rs b/tests/watch_flake_smoke.rs index 4890902..139d01e 100644 --- a/tests/watch_flake_smoke.rs +++ b/tests/watch_flake_smoke.rs @@ -1,11 +1,10 @@ -use std::fs; use std::io::{BufRead, BufReader}; use std::process::{Child, Command, Stdio}; use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use serde_json::Value; use tempfile::TempDir; #[test] @@ -19,7 +18,7 @@ fn repeated_literal_file_edits_keep_triggering_native_watch_workflow() { for write_index in 1..=10 { let value = format!("native-trial-{}", "x".repeat(write_index)); fixture.write_value(&value); - fixture.wait_for_state_value(&value, Duration::from_secs(10)); + child.wait_for_log_line(&format!("changed value: {value}"), Duration::from_secs(10)); } } @@ -70,58 +69,23 @@ steps = [ self.dir.path().join("devloop.toml") } - fn state_path(&self) -> std::path::PathBuf { - self.dir.path().join(".devloop/state.json") - } - fn write_value(&self, value: &str) { self.write("watched.txt", &format!("{value}\n")); } - fn wait_for_state_value(&self, value: &str, timeout: Duration) { - let deadline = Instant::now() + timeout; - loop { - if let Ok(raw) = fs::read_to_string(self.state_path()) - && let Ok(json) = serde_json::from_str::(&raw) - && json - .get("current_value") - .and_then(Value::as_str) - .is_some_and(|current| current == value) - { - return; - } - - if Instant::now() > deadline { - let current = fs::read_to_string(self.state_path()) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .and_then(|json| { - json.get("current_value") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }); - panic!( - "timed out waiting for current_value '{value}', last observed value: {:?}", - current - ); - } - - std::hint::spin_loop(); - } - } - fn write(&self, relative_path: &str, contents: &str) { let path = self.dir.path().join(relative_path); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create fixture parent directories"); + std::fs::create_dir_all(parent).expect("create fixture parent directories"); } - fs::write(path, contents).expect("write fixture file"); + std::fs::write(path, contents).expect("write fixture file"); } } struct DevloopChild { child: Child, lines: Receiver, + history: Arc>>, } impl DevloopChild { @@ -137,11 +101,17 @@ impl DevloopChild { let mut child = command.spawn().expect("spawn devloop"); let stderr = child.stderr.take().expect("take child stderr"); let (tx, rx) = mpsc::channel(); + let history = Arc::new(Mutex::new(Vec::new())); + let history_writer = Arc::clone(&history); thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) => { + history_writer + .lock() + .expect("lock log history") + .push(line.clone()); if tx.send(line).is_err() { return; } @@ -150,7 +120,11 @@ impl DevloopChild { } } }); - Self { child, lines: rx } + Self { + child, + lines: rx, + history, + } } fn wait_for_log_line(&mut self, needle: &str, timeout: Duration) { @@ -159,10 +133,12 @@ impl DevloopChild { let remaining = deadline .checked_duration_since(Instant::now()) .unwrap_or_else(|| Duration::from_secs(0)); - let line = self - .lines - .recv_timeout(remaining) - .unwrap_or_else(|_| panic!("timed out waiting for log line containing '{needle}'")); + let line = self.lines.recv_timeout(remaining).unwrap_or_else(|_| { + let history = self.history.lock().expect("lock log history"); + panic!( + "timed out waiting for log line containing '{needle}'. recent logs: {history:?}" + ); + }); if line.contains(needle) { return; } From 2c112c1fc89feb1b62bd140807a3079f2e78019d Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:51:57 +1000 Subject: [PATCH 3/8] Handle missing directory watch targets --- src/engine.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/engine.rs b/src/engine.rs index c99b9f4..5df4517 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -572,9 +572,10 @@ fn resolve_watch_registrations( .parent() .ok_or_else(|| anyhow!("watch target '{}' has no parent", target.path.display()))?; if immediate_parent.exists() { + let recursive = target.path.extension().is_none(); return Ok(vec![CompiledWatchTarget { path: immediate_parent.to_path_buf(), - recursive: false, + recursive, }]); } @@ -779,6 +780,29 @@ mod tests { ); } + #[test] + fn resolve_watch_registration_uses_recursive_parent_for_missing_directory_like_target() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("content"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: target, + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: true, + }] + ); + } + #[test] fn resolve_watch_registration_climbs_recursively_when_parent_is_missing() { let dir = tempdir().expect("tempdir"); From be7cb307edfdd263bf677c43c8c8e13e7bb28b80 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:54:26 +1000 Subject: [PATCH 4/8] Add explicit literal directory watch syntax --- docs/configuration.md | 2 ++ src/config.rs | 23 ++++++++++++++++++++++- src/engine.rs | 7 +++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0afc04e..a532b42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,6 +63,8 @@ workflow = "rust" - Table name: the watch-group name. - `paths`: glob patterns evaluated relative to `root`. + Use a trailing `/` for a literal directory target that should be + watched recursively even before it exists. - `workflow`: workflow to run when a matching file changes. If omitted, the watch-group name is used as the workflow name. diff --git a/src/config.rs b/src/config.rs index a1caedc..07d05a1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -307,7 +307,7 @@ impl CompiledWatchTarget { if pattern_is_literal(pattern) { return Self { path: root.join(prefix), - recursive: false, + recursive: pattern.ends_with('/'), }; } @@ -1362,6 +1362,27 @@ mod tests { ); } + #[test] + fn compiled_watch_targets_keep_literal_directory_targets_recursive() { + let mut config = base_config(); + config.root = PathBuf::from("/tmp/example"); + config.watch.insert( + "content".into(), + WatchGroup { + paths: vec!["content/".into()], + workflow: Some("content".into()), + }, + ); + + assert_eq!( + config.compiled_watch_targets(), + vec![CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content"), + recursive: true, + }] + ); + } + #[test] fn config_detects_notify_reload_in_nested_workflow() { let mut config = base_config(); diff --git a/src/engine.rs b/src/engine.rs index 5df4517..c674dc4 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -572,10 +572,9 @@ fn resolve_watch_registrations( .parent() .ok_or_else(|| anyhow!("watch target '{}' has no parent", target.path.display()))?; if immediate_parent.exists() { - let recursive = target.path.extension().is_none(); return Ok(vec![CompiledWatchTarget { path: immediate_parent.to_path_buf(), - recursive, + recursive: false, }]); } @@ -781,14 +780,14 @@ mod tests { } #[test] - fn resolve_watch_registration_uses_recursive_parent_for_missing_directory_like_target() { + fn resolve_watch_registration_uses_recursive_parent_for_missing_explicit_directory_target() { let dir = tempdir().expect("tempdir"); let target = dir.path().join("content"); let registrations = resolve_watch_registrations( &CompiledWatchTarget { path: target, - recursive: false, + recursive: true, }, WatcherKind::Native, ) From 91cc731aca2e24aa6b040207cb61bd73d00597b5 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:56:53 +1000 Subject: [PATCH 5/8] Preserve explicit directory watch fallback --- src/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine.rs b/src/engine.rs index c674dc4..c2b4406 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -574,7 +574,7 @@ fn resolve_watch_registrations( if immediate_parent.exists() { return Ok(vec![CompiledWatchTarget { path: immediate_parent.to_path_buf(), - recursive: false, + recursive: target.recursive, }]); } From 1f882215a1bd15dcd15def8f297e2ac4d2c52131 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:20:26 +1000 Subject: [PATCH 6/8] Prepare 0.8.0 release docs and tests --- AGENTS.md | 4 ++ CHANGELOG.md | 21 ++++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++ docs/README.md | 1 + docs/behavior.md | 3 ++ docs/configuration.md | 14 ++++--- docs/development.md | 46 ++++++++++++++++++++++ src/main.rs | 81 ++++++++++++++++++++++++++++++++------ src/processes.rs | 77 +++++++++++++++++++++++++----------- tests/watch_flake_smoke.rs | 5 +++ 12 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 docs/development.md diff --git a/AGENTS.md b/AGENTS.md index cc2cd32..8d2ada4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,10 @@ without hard-coding knowledge of any one repository. - Do not use `sleep` to resolve races. Races must be resolved with deterministic logic, explicit readiness signals, or ordered state transitions. +- When tests must mutate process-global state such as environment + variables, isolate the unsafe operation in a small helper, serialize + access with a lock, and document the safety rationale instead of + scattering raw unsafe calls through test bodies. - Run quality gates for code changes: `cargo fmt`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c5533cb..497a40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,38 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.8.0] - 2026-04-08 + ### Added - Added a configurable watcher backend with non-breaking `native` default behavior plus a `poll` fallback mode for environments where native filesystem notifications are unreliable. -- Added a Rust repeated-edit watch flake smoke test that runs under - `cargo test` and therefore in GitHub Actions alongside the existing - runtime smoke test. +- Added a Rust repeated-edit watch flake smoke test that can be run + locally with `DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 cargo test --test + watch_flake_smoke -- --nocapture`. +- Added explicit trailing-slash syntax for literal directory watch + targets, for example `content/`, so recursive directory intent is + preserved even when the directory does not yet exist at startup. +- Added a development guide under [`docs/development.md`](docs/development.md) + and exposed it in the CLI as `devloop docs development`. ### Changed - `devloop` now derives concrete watch targets from configured watch patterns and asks the backend to watch only those files or directories instead of always watching the whole repository root. +- The watch flake smoke test is now opt-in instead of running during + every default `cargo test` or CI run. The existing runtime smoke test + remains in CI. ### Fixed - Native watch registration now resolves file and directory targets at runtime, so startup no longer depends on those paths already existing when config is parsed. +- Fixed a real watch flake where the debounce batch could be dropped if + another `tokio::select!` branch won the race while filesystem events + were already buffered. +- Test-only environment mutation now lives behind locked helpers with + documented safety rationale instead of scattered raw unsafe blocks. ## [0.7.0] - 2026-03-27 diff --git a/Cargo.lock b/Cargo.lock index 8af0ae5..86a1baf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 7118abb..eeba7c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.7.0" +version = "0.8.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index d28b3c6..2e3a46a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Built-in reference docs are also available from the CLI: ```bash devloop docs config devloop docs behavior +devloop docs development devloop docs security ``` @@ -186,6 +187,9 @@ For the runtime behavior reference, see For the full configuration reference, see [`docs/configuration.md`](docs/configuration.md). +For local contributor workflow details, including the opt-in watch +flake smoke test, see [`docs/development.md`](docs/development.md). + For the external-event trust model and push-versus-polling tradeoffs, see [`docs/security.md`](docs/security.md). diff --git a/docs/README.md b/docs/README.md index 99f9df9..5e618e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ - [Behavior Reference](behavior.md) - [Configuration Reference](configuration.md) +- [Development Guide](development.md) - [Security Notes](security.md) This directory holds detailed reference material for `devloop`. diff --git a/docs/behavior.md b/docs/behavior.md index 82a6971..41f57b9 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -60,6 +60,9 @@ watch-group patterns and watches only those files or directories. - The default backend uses native filesystem notifications. A polling 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. + Use a trailing `/` in the config when you mean an explicit directory + target that should be watched recursively. If multiple watch groups map to the same workflow, their matched paths are merged for that workflow run. diff --git a/docs/configuration.md b/docs/configuration.md index a532b42..4986bca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,21 +56,25 @@ bind = "127.0.0.1:0" Watch groups map file patterns to workflows. ```toml -[watch.rust] -paths = ["src/**/*.rs", "Cargo.toml"] -workflow = "rust" +[watch.content] +paths = ["content/", "templates/**/*.html"] +workflow = "content" ``` - Table name: the watch-group name. - `paths`: glob patterns evaluated relative to `root`. Use a trailing `/` for a literal directory target that should be - watched recursively even before it exists. + watched recursively, including when the directory may not exist yet + at startup. Without the trailing slash, a literal path is treated as + a file target. - `workflow`: workflow to run when a matching file changes. If omitted, the watch-group name is used as the workflow name. `devloop` derives concrete watch targets from these patterns and asks the backend to watch only those literal files or directories instead of -always watching the whole repository root recursively. +always watching the whole repository root recursively. `native` remains +the default backend; `poll` exists as a fallback for environments where +filesystem notifications are unreliable. ## Processes diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ac064c1 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,46 @@ +# Development Guide + +This guide covers local development workflows for `devloop` itself. + +## Quality gates + +Run the standard checks before committing: + +```bash +cargo fmt +cargo test +cargo clippy --all-targets --all-features -- -D warnings +./scripts/ci-smoke.sh +``` + +`./scripts/ci-smoke.sh` is the fast runtime smoke test used in CI. It +checks that `devloop run` can start, begin watching, react to one file +change, and shut down cleanly. + +## Opt-in watch flake smoke + +The repeated-edit watch flake smoke test is intentionally opt-in. It is +useful when changing watch registration, debounce logic, or event +delivery, but it is slower and more environment-sensitive than the +standard test suite. + +Run it locally with: + +```bash +DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 cargo test --test watch_flake_smoke -- --nocapture +``` + +Without that environment variable, the test exits early so normal +`cargo test` and CI runs stay fast. + +## Test policy + +When a test must mutate process-global state such as environment +variables: + +- serialize access with a test-local lock +- keep `unsafe` in a narrow helper +- document the safety rationale at the helper + +Do not scatter raw `unsafe { std::env::set_var(...) }` calls across test +bodies. diff --git a/src/main.rs b/src/main.rs index 966351c..91a00b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ enum Command { enum DocsTopic { Config, Behavior, + Development, Security, } @@ -151,6 +152,7 @@ fn docs_text(topic: DocsTopic) -> &'static str { match topic { DocsTopic::Config => include_str!("../docs/configuration.md"), DocsTopic::Behavior => include_str!("../docs/behavior.md"), + DocsTopic::Development => include_str!("../docs/development.md"), DocsTopic::Security => include_str!("../docs/security.md"), } } @@ -345,32 +347,69 @@ mod tests { format_output_prefix, normalize_internal_log_label, normalize_source_label, }; use clap::Parser; - use std::sync::{Mutex, OnceLock}; + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard, OnceLock}; fn rust_log_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } - #[test] - fn default_rust_log_uses_info_when_unset() { - let _guard = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); + struct RustLogGuard { + _lock: MutexGuard<'static, ()>, + original: Option, + } + + impl RustLogGuard { + fn set(value: Option<&str>) -> Self { + let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); + let original = std::env::var_os("RUST_LOG"); + match value { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + Self { + _lock: lock, + original, + } + } + } + + impl Drop for RustLogGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + } + } + + fn set_test_env_var(key: &str, value: impl AsRef) { + // SAFETY: tests serialize all RUST_LOG mutation through `rust_log_lock`, + // so no concurrent test can observe partially updated process-global state. unsafe { - std::env::remove_var("RUST_LOG"); + std::env::set_var(key, value); } + } + + fn remove_test_env_var(key: &str) { + // SAFETY: tests serialize all RUST_LOG mutation through `rust_log_lock`, + // so removing the variable cannot race with other tests here. + unsafe { + std::env::remove_var(key); + } + } + + #[test] + fn default_rust_log_uses_info_when_unset() { + let _guard = RustLogGuard::set(None); assert_eq!(default_rust_log(), "info"); } #[test] fn default_rust_log_respects_environment_override() { - let _guard = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - unsafe { - std::env::set_var("RUST_LOG", "debug,devloop=trace"); - } + let _guard = RustLogGuard::set(Some("debug,devloop=trace")); assert_eq!(default_rust_log(), "debug,devloop=trace"); - unsafe { - std::env::remove_var("RUST_LOG"); - } } #[test] @@ -418,6 +457,14 @@ mod tests { assert!(rendered.contains("startup_workflows")); } + #[test] + fn docs_text_uses_embedded_development_reference() { + let rendered = docs_text(DocsTopic::Development); + + assert!(rendered.starts_with("# Development Guide")); + assert!(rendered.contains("DEVLOOP_RUN_WATCH_FLAKE_SMOKE")); + } + #[test] fn rendered_docs_drop_markdown_heading_markers() { let rendered = render_docs_text(DocsTopic::Config); @@ -460,4 +507,14 @@ mod tests { _ => panic!("expected docs subcommand"), } } + + #[test] + fn cli_parses_development_docs_subcommand() { + let cli = Cli::try_parse_from(["devloop", "docs", "development"]).expect("parse cli"); + + match cli.command { + super::Command::Docs { topic } => assert!(matches!(topic, DocsTopic::Development)), + _ => panic!("expected docs subcommand"), + } + } } diff --git a/src/processes.rs b/src/processes.rs index c09887f..06c12a3 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -985,11 +985,63 @@ mod tests { use super::*; use crate::config::{OutputExtract, ProbeSpec}; use serde_json::Value; + use std::ffi::OsString; use std::sync::Arc; + use std::sync::{Mutex as StdMutex, MutexGuard, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::io::AsyncReadExt; use tokio::sync::Mutex; + fn rust_log_lock() -> &'static StdMutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())) + } + + struct RustLogGuard { + _lock: MutexGuard<'static, ()>, + original: Option, + } + + impl RustLogGuard { + fn set(value: Option<&str>) -> Self { + let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); + let original = std::env::var_os("RUST_LOG"); + match value { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + Self { + _lock: lock, + original, + } + } + } + + impl Drop for RustLogGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + } + } + + fn set_test_env_var(key: &str, value: impl AsRef) { + // SAFETY: these tests serialize process-global env mutation through + // `rust_log_lock`, so no concurrent test observes a torn update. + unsafe { + std::env::set_var(key, value); + } + } + + fn remove_test_env_var(key: &str) { + // SAFETY: these tests serialize process-global env mutation through + // `rust_log_lock`, so removing the variable does not race here. + unsafe { + std::env::remove_var(key); + } + } + fn unique_state_path() -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1323,10 +1375,7 @@ mod tests { #[test] fn configure_command_inherits_parent_rust_log_by_default() { - let original = std::env::var_os("RUST_LOG"); - unsafe { - std::env::set_var("RUST_LOG", "debug"); - } + let _guard = RustLogGuard::set(Some("debug")); let command = configure_command( &["cargo".into(), "run".into()], @@ -1352,16 +1401,11 @@ mod tests { rust_log.is_none(), "RUST_LOG should not be overridden in child env" ); - - restore_rust_log(original); } #[test] fn configure_command_keeps_explicit_rust_log_override() { - let original = std::env::var_os("RUST_LOG"); - unsafe { - std::env::set_var("RUST_LOG", "debug"); - } + let _guard = RustLogGuard::set(Some("debug")); let mut env = BTreeMap::new(); env.insert("RUST_LOG".into(), "info,gcp_rust_blog=debug".into()); @@ -1389,8 +1433,6 @@ mod tests { .expect("explicit RUST_LOG should be preserved"); assert_eq!(rust_log, "info,gcp_rust_blog=debug"); - - restore_rust_log(original); } #[test] @@ -1463,17 +1505,6 @@ mod tests { })); } - fn restore_rust_log(original: Option) { - match original { - Some(value) => unsafe { - std::env::set_var("RUST_LOG", value); - }, - None => unsafe { - std::env::remove_var("RUST_LOG"); - }, - } - } - #[test] fn output_color_code_is_stable_for_same_process() { assert_eq!( diff --git a/tests/watch_flake_smoke.rs b/tests/watch_flake_smoke.rs index 139d01e..e865b20 100644 --- a/tests/watch_flake_smoke.rs +++ b/tests/watch_flake_smoke.rs @@ -9,6 +9,11 @@ use tempfile::TempDir; #[test] fn repeated_literal_file_edits_keep_triggering_native_watch_workflow() { + if std::env::var_os("DEVLOOP_RUN_WATCH_FLAKE_SMOKE").is_none() { + eprintln!("skipping watch flake smoke; set DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 to run it"); + return; + } + let fixture = WatchFixture::new(); let mut child = DevloopChild::spawn(&fixture); From edce38b94162a129bcb52a21c8bd8d8beb269cd1 Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:23:19 +1000 Subject: [PATCH 7/8] Share test env lock across modules --- src/main.rs | 55 +++------------------------------------------ src/processes.rs | 53 +------------------------------------------ src/test_support.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 104 deletions(-) create mode 100644 src/test_support.rs diff --git a/src/main.rs b/src/main.rs index 91a00b0..87cb103 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod external_events; mod output; mod processes; mod state; +#[cfg(test)] +mod test_support; use std::path::PathBuf; @@ -346,59 +348,8 @@ mod tests { use crate::output::{ format_output_prefix, normalize_internal_log_label, normalize_source_label, }; + use crate::test_support::RustLogGuard; use clap::Parser; - use std::ffi::OsString; - use std::sync::{Mutex, MutexGuard, OnceLock}; - - fn rust_log_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - struct RustLogGuard { - _lock: MutexGuard<'static, ()>, - original: Option, - } - - impl RustLogGuard { - fn set(value: Option<&str>) -> Self { - let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - let original = std::env::var_os("RUST_LOG"); - match value { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), - } - Self { - _lock: lock, - original, - } - } - } - - impl Drop for RustLogGuard { - fn drop(&mut self) { - match &self.original { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), - } - } - } - - fn set_test_env_var(key: &str, value: impl AsRef) { - // SAFETY: tests serialize all RUST_LOG mutation through `rust_log_lock`, - // so no concurrent test can observe partially updated process-global state. - unsafe { - std::env::set_var(key, value); - } - } - - fn remove_test_env_var(key: &str) { - // SAFETY: tests serialize all RUST_LOG mutation through `rust_log_lock`, - // so removing the variable cannot race with other tests here. - unsafe { - std::env::remove_var(key); - } - } #[test] fn default_rust_log_uses_info_when_unset() { diff --git a/src/processes.rs b/src/processes.rs index 06c12a3..2c81327 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -984,64 +984,13 @@ fn timeout_error(name: &str, probe: &ProbeSpec) -> anyhow::Error { mod tests { use super::*; use crate::config::{OutputExtract, ProbeSpec}; + use crate::test_support::RustLogGuard; use serde_json::Value; - use std::ffi::OsString; use std::sync::Arc; - use std::sync::{Mutex as StdMutex, MutexGuard, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::io::AsyncReadExt; use tokio::sync::Mutex; - fn rust_log_lock() -> &'static StdMutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| StdMutex::new(())) - } - - struct RustLogGuard { - _lock: MutexGuard<'static, ()>, - original: Option, - } - - impl RustLogGuard { - fn set(value: Option<&str>) -> Self { - let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - let original = std::env::var_os("RUST_LOG"); - match value { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), - } - Self { - _lock: lock, - original, - } - } - } - - impl Drop for RustLogGuard { - fn drop(&mut self) { - match &self.original { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), - } - } - } - - fn set_test_env_var(key: &str, value: impl AsRef) { - // SAFETY: these tests serialize process-global env mutation through - // `rust_log_lock`, so no concurrent test observes a torn update. - unsafe { - std::env::set_var(key, value); - } - } - - fn remove_test_env_var(key: &str) { - // SAFETY: these tests serialize process-global env mutation through - // `rust_log_lock`, so removing the variable does not race here. - unsafe { - std::env::remove_var(key); - } - } - fn unique_state_path() -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..80751b5 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,54 @@ +use std::ffi::{OsStr, OsString}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +fn rust_log_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +pub(crate) struct RustLogGuard { + _lock: MutexGuard<'static, ()>, + original: Option, +} + +impl RustLogGuard { + pub(crate) fn set(value: Option<&str>) -> Self { + let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); + let original = std::env::var_os("RUST_LOG"); + match value { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + Self { + _lock: lock, + original, + } + } +} + +impl Drop for RustLogGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + } +} + +fn set_test_env_var(key: &str, value: impl AsRef) { + // SAFETY: all test-time RUST_LOG mutation goes through the shared + // `rust_log_lock`, so no concurrent unit test can race on this + // process-global environment state. + unsafe { + std::env::set_var(key, value); + } +} + +fn remove_test_env_var(key: &str) { + // SAFETY: all test-time RUST_LOG mutation goes through the shared + // `rust_log_lock`, so removing the variable cannot race with another + // unit test in this process. + unsafe { + std::env::remove_var(key); + } +} From 8256420e8b071695e319f4083a45fd6beadcf2bb Mon Sep 17 00:00:00 2001 From: Daniel Vianna <1708810+dmvianna@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:47:57 +1000 Subject: [PATCH 8/8] Sync bd issue state --- .beads/issues.jsonl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0fdff0e..207bd97 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,10 +1,13 @@ {"id":"devloop-0nx","title":"Define project docs, AGENTS, and roadmap","description":"Add README, PLAN, and repository-specific AGENTS guidance for the standalone tool.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T01:27:27Z","closed_at":"2026-03-24T01:27:27Z","close_reason":"Closed"} {"id":"devloop-8km","title":"Implement config and workflow engine","description":"Load config, watch paths, classify events, and execute ordered workflows against named processes and hooks.","status":"closed","priority":2,"issue_type":"feature","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed","dependencies":[{"issue_id":"devloop-8km","depends_on_id":"devloop-0nx","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} +{"id":"devloop-c6l","title":"Polish 0.8.0 release docs and test ergonomics","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T07:12:58Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T07:20:27Z","closed_at":"2026-04-08T07:20:27Z","close_reason":"Closed"} {"id":"devloop-d81","title":"Move real client config out of devloop examples","description":"Keep only generic examples in devloop and move the working blog config into the client repository root.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:31:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:39:07Z","closed_at":"2026-03-24T02:39:07Z","close_reason":"Kept only generic examples in devloop and moved the working blog config into the client repo root."} {"id":"devloop-dzy","title":"Make client hook paths repo-relative","description":"Resolve devloop config command paths relative to the client repo or config, not the tool checkout.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:16:34Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:22:17Z","closed_at":"2026-03-24T02:22:17Z","close_reason":"Closed"} {"id":"devloop-mml","title":"Address roborev findings on state ownership and client-specific URL composition","status":"closed","priority":1,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:44:51Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:53:31Z","closed_at":"2026-03-24T02:53:31Z","close_reason":"Shared session state is now owned in memory, generic state templating replaced blog-specific derivation, redundant writes are skipped, and the review job was addressed."} {"id":"devloop-nmu","title":"Add blog client example and verification","description":"Create example config/hooks for the blog repo and verify the tool runs against it.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed","dependencies":[{"issue_id":"devloop-nmu","depends_on_id":"devloop-8km","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} {"id":"devloop-s2h","title":"Bootstrap configurable dev-loop engine MVP","description":"Create a standalone Rust CLI in /tmp/devloop with config-driven file watching, process supervision, workflows, hooks, and documentation. Use the blog repo as the first client.","status":"open","priority":2,"issue_type":"epic","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:14:54Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T01:14:54Z","dependencies":[{"issue_id":"devloop-s2h","depends_on_id":"devloop-nmu","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} {"id":"devloop-ufx","title":"Add client adapter for dynamic tunnel url consumption","description":"The blog app still treats SITE_URL as startup-only state. Add a client-side integration pattern that reads devloop state dynamically so tunnel restarts affect rendered metadata.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:27:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed"} +{"id":"devloop-uog","title":"Add watcher regression smoke test and poll backend fallback","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T05:45:33Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T06:46:22Z","closed_at":"2026-04-08T06:46:22Z","close_reason":"Closed"} {"id":"devloop-vxg","title":"Support process-emitted state updates","description":"Long-running processes such as cloudflared need a first-class way to publish readiness and state into the engine instead of relying on wrapper scripts mutating the session file.","status":"closed","priority":2,"issue_type":"feature","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:27:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed"} {"id":"devloop-w34","title":"Support workflow composition to reduce repeated setup steps","description":"Add a generic way for one workflow to reuse another so client configs can avoid duplicating repeated step sequences such as wait-for-tunnel plus templated write_state composition.","status":"closed","priority":3,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:53:31Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T03:01:13Z","closed_at":"2026-03-24T03:01:13Z","close_reason":"Added reusable run_workflow steps, validated nested workflow recursion and missing references, and updated the generic example."} +{"id":"devloop-y2l","title":"Fix repeated literal file edit watch flakiness","description":"A Rust integration smoke test now reproduces missed workflow triggers during repeated edits to the same watched file. Fix the watch pipeline so the smoke test passes deterministically without sleeps.","status":"closed","priority":1,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T06:23:10Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T06:46:22Z","closed_at":"2026-04-08T06:46:22Z","close_reason":"Closed"}