Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.10.3] - 2026-08-26

### Fixed

- Made `ctrl-c` shutdown tolerate redundant or overlapping watch targets, so
native watcher teardown cannot skip managed-process cleanup or turn a normal
exit into an error.

## [0.10.2] - 2026-08-26

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devloop"
version = "0.10.2"
version = "0.10.3"
edition = "2024"

[dependencies]
Expand Down
12 changes: 9 additions & 3 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,15 @@ output-derived updates.
On `ctrl-c`, `devloop`:

1. marks itself as shutting down
2. stops all managed processes
3. suppresses further automatic restarts
4. exits
2. stops watching without requiring configured watch targets to be
disjoint or unique
3. stops all managed processes
4. suppresses further automatic restarts
5. exits successfully

Overlapping recursive and literal watch patterns are valid. A redundant
watch registration or an already-removed backend watch cannot interrupt
process cleanup during shutdown.

## Known non-goals

Expand Down
12 changes: 10 additions & 2 deletions scripts/ci-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ state_path = pathlib.Path(sys.argv[1])
deadline = time.time() + 15
while time.time() < deadline:
if state_path.exists():
data = json.loads(state_path.read_text())
try:
data = json.loads(state_path.read_text())
except json.JSONDecodeError:
time.sleep(0.1)
continue
if data.get("current_value") == "initial":
sys.exit(0)
time.sleep(0.1)
Expand Down Expand Up @@ -123,7 +127,11 @@ while time.time() < deadline:
watched_path.write_text("updated\n")
next_write = now + 0.5
if state_path.exists():
data = json.loads(state_path.read_text())
try:
data = json.loads(state_path.read_text())
except json.JSONDecodeError:
time.sleep(0.1)
continue
if (
data.get("current_value") == "updated"
and data.get("current_url") == "devloop://updated"
Expand Down
75 changes: 62 additions & 13 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use notify::{
use serde_json::{Map, Value};
use tokio::signal;
use tokio::time::{Instant, sleep};
use tracing::{error, info};
use tracing::{error, info, warn};
use unicode_width::UnicodeWidthStr;

use crate::browser_reload::{BrowserReloadSender, BrowserReloadServer, notify_browser_reload};
Expand Down Expand Up @@ -76,7 +76,6 @@ struct LiveRuntimeAdapter<'a, 'b> {
watcher: &'a mut Box<dyn Watcher + Send>,
watcher_shutdown: Arc<AtomicBool>,
watched_targets: Vec<CompiledWatchTarget>,
active_watch_targets: Vec<CompiledWatchTarget>,
external_event_tx: tokio::sync::mpsc::UnboundedSender<ExternalEventMessage>,
external_event_server: Option<ExternalEventServer>,
browser_reload_server: Option<BrowserReloadServer>,
Expand Down Expand Up @@ -135,7 +134,6 @@ 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,
Expand Down Expand Up @@ -318,7 +316,6 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> {
}

async fn start_watching(&mut self) -> Result<()> {
self.active_watch_targets.clear();
let mut registrations = BTreeMap::<std::path::PathBuf, bool>::new();
for target in &self.watched_targets {
for registration in resolve_watch_registrations(target, self.config.watcher.kind)? {
Expand All @@ -339,7 +336,6 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> {
RecursiveMode::NonRecursive
},
)?;
self.active_watch_targets.push(registration.clone());
info!(
"watching {}{}",
registration.path.display(),
Expand Down Expand Up @@ -369,11 +365,11 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> {
}

async fn stop_watching(&mut self) -> Result<()> {
// Native watcher backends may represent overlapping recursive and
// literal registrations with the same underlying OS watches. Dropping
// the whole watcher is the idempotent shutdown operation; unregistering
// each configured target can remove a descendant twice.
self.watcher_shutdown.store(true, Ordering::Relaxed);
for target in &self.active_watch_targets {
self.watcher.unwatch(&target.path)?;
}
self.active_watch_targets.clear();
Ok(())
}

Expand Down Expand Up @@ -437,7 +433,14 @@ async fn execute_runtime_effects<A: RuntimeEffectAdapter>(
}
}
RuntimeEffect::LogInfo { message } => adapter.log_info(message).await?,
RuntimeEffect::StopWatching => adapter.stop_watching().await?,
RuntimeEffect::StopWatching => {
if let Err(error) = adapter.stop_watching().await {
warn!(
error = %error,
"watcher teardown failed; continuing process cleanup"
);
}
}
RuntimeEffect::StopAllProcesses => adapter.stop_all_processes().await?,
RuntimeEffect::Exit => return Ok(true),
}
Expand All @@ -456,6 +459,9 @@ fn forward_watcher_event(
mut result: notify::Result<Event>,
ignored_paths: &[PathBuf],
) {
if shutting_down.load(Ordering::Relaxed) {
return;
}
if let Ok(event) = &mut result {
event.paths.retain(|path| {
!ignored_paths
Expand Down Expand Up @@ -1595,6 +1601,7 @@ mod tests {
calls: Vec<String>,
changed_hooks: BTreeMap<String, bool>,
workflow_errors: BTreeMap<String, String>,
stop_watching_error: Option<String>,
watching: bool,
}

Expand All @@ -1604,6 +1611,7 @@ mod tests {
calls: Vec::new(),
changed_hooks: BTreeMap::new(),
workflow_errors: BTreeMap::new(),
stop_watching_error: None,
watching: false,
}
}
Expand Down Expand Up @@ -1668,6 +1676,9 @@ mod tests {

async fn stop_watching(&mut self) -> Result<()> {
self.calls.push("stop_watch".into());
if let Some(message) = &self.stop_watching_error {
return Err(anyhow!(message.clone()));
}
self.watching = false;
Ok(())
}
Expand Down Expand Up @@ -1763,11 +1774,47 @@ mod tests {
);
}

#[tokio::test]
async fn ctrl_c_continues_cleanup_when_watcher_teardown_reports_missing_watch() {
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(),
process: BTreeMap::new(),
hook: BTreeMap::new(),
event_server: crate::config::EventServerConfig::default(),
browser_reload_server: crate::config::BrowserReloadServerConfig::default(),
event: BTreeMap::new(),
workflow: BTreeMap::new(),
};
let mut runtime = RuntimeMachine::new(&config);
let mut adapter = MockRuntimeAdapter::new();
adapter.stop_watching_error =
Some("No watch was found. about [\"content/banner.html\"]".into());

runtime.handle_event(RuntimeEvent::CtrlC);
let exit = execute_runtime_effects(&mut runtime, &mut adapter)
.await
.expect("watcher teardown must not abort the remaining shutdown effects");

assert!(exit);
assert_eq!(
adapter.calls,
vec![
"log:received ctrl-c, shutting down",
"stop_watch",
"stop_all",
]
);
}

#[test]
fn forward_watcher_event_ignores_send_failures_after_shutdown() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
fn forward_watcher_event_ignores_events_after_shutdown() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let shutdown = AtomicBool::new(true);
drop(rx);

forward_watcher_event(
&tx,
Expand All @@ -1779,6 +1826,8 @@ mod tests {
}),
&[],
);

assert!(rx.try_recv().is_err());
}

#[test]
Expand Down