The notify crate is surprisingly pleasant — I built a cross-platform file watcher in four dependencies
entr,cargo-watch, andnodemonare all fine. They're also each tied to one ecosystem. I wanted a single 10 MB binary I could drop into any project's Makefile.watch-execis that binary, and building it taught me that writing a correct file watcher is mostly about what you don't do.
📦 GitHub: https://github.com/sen-ltd/watch-exec
Every language has its own "please re-run this command when I save a file" tool. JavaScript has nodemon. Rust has cargo-watch. Python has watchmedo. The BSD-derived world has entr. They're all good, and they're all annoying for the same reason: if you work in more than one language in a week (and most of us do), you're installing and remembering four different CLIs with four different argument grammars for what is fundamentally one problem.
The problem is: watch files, run a command, coalesce bursts, kill hangers, go.
I wanted a tool that did nothing else. watch-exec is 600 lines of Rust across four source files and ships as a 9.6 MB Alpine-based Docker image. Total dependencies: clap, notify, glob, ctrlc. That's it. This article is the field report.
What makes a file watcher annoying to write
Before touching code, I sat down and listed everything that a naive implementation gets wrong:
- Bursts. Saving a single file from Vim or VSCode emits anywhere from 3 to 12 filesystem events — create, modify-metadata, modify-content, rename, chmod, and sometimes an event on a sibling temp file. If you rerun your command once per event you're running it 3–12x per save. Unusable.
-
Hanging children. Your command might be
cargo runorpython manage.py runserver, which never exit. When a file changes, you have to kill the previous child first, then spawn the new one. If you forget, you accumulate zombie processes and the rerun appears to silently do nothing. -
Glob semantics. Users expect
*.rsto match top-level files only and**/*.rsto recurse — the gitignore convention, not the shell's literal default. Theglobcrate supports both viaMatchOptions.require_literal_separator, and picking the wrong default bites people two years later. -
Events during the rerun. Your command itself touches files.
cargo testwrites totarget/. If you don't excludetarget/**, the rerun triggers another rerun, and you have an infinite loop that heats the room. - Testing. File watchers depend on real filesystem events, which are flaky, slow, and platform-specific. If you couple your tests to them, you're signing up for a test suite that takes 30 seconds and fails randomly in CI.
Problems 1, 2, and 5 ate most of my design time. Let me walk through each.
1. The debounce trick: "eat all pending events in a window"
Here's the full debounce loop:
pub fn run_debounce_window(
rx: &Receiver<PathBuf>,
root: &Path,
matcher: &Matcher,
debounce: Duration,
) -> DebounceOutcome {
// 1. Block on the first event.
let first = match rx.recv() {
Ok(p) => p,
Err(_) => return DebounceOutcome::Closed,
};
let deadline = Instant::now() + debounce;
let mut bucket: Vec<PathBuf> = vec![first];
// 2. Drain until the window expires.
loop {
let now = Instant::now();
if now >= deadline { break; }
let remaining = deadline - now;
match rx.recv_timeout(remaining) {
Ok(p) => bucket.push(p),
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => break,
}
}
// 3. Filter and decide.
let interesting: Vec<_> = bucket.into_iter()
.map(|p| relativize(root, &p))
.filter(|p| matcher.is_match(p))
.collect();
if interesting.is_empty() {
DebounceOutcome::Ignored
} else {
DebounceOutcome::Fire { paths: interesting }
}
}
Three rules encoded in twenty lines:
- First event blocks. We sleep on the channel until something happens. No polling, no wasted CPU.
-
Subsequent events are drained with a shrinking timeout. The trick that took me a minute to appreciate:
recv_timeout(remaining)whereremaining = deadline - now. Each new event resets nothing — the window is anchored to the arrival of the first event, not the most recent one. That means bursts of 10 events in 30 ms produce one rerun, but a slow trickle of one event every 300 ms (longer than the window) produces one rerun per event, which is also correct: the user is slowly touching files, each one is a distinct save. - Filtering happens once, at the end. We don't apply the matcher on the way in. That means the matcher could be expensive and we still only pay for it once per window. More importantly, it means tests can inject raw paths and see the coalescing logic in isolation.
That last point is what makes the test suite possible.
2. Killing the hanging child
watch-exec --glob '**/*.rs' -- cargo run has to handle the case where cargo run is still running when you save again. The rule is: kill it, then start a new one.
pub fn trigger(&mut self) -> io::Result<()> {
if let Some(mut c) = self.current.take() {
// If it's still alive, SIGKILL it. If already exited, fine.
if let Ok(None) = c.try_wait() {
c.kill()?;
// Reap the zombie.
let _ = c.wait();
self.kill_count += 1;
}
}
let child = self.spawner.spawn(&self.cmd)?;
self.current = Some(child);
self.spawn_count += 1;
Ok(())
}
Two details worth pointing out:
-
try_wait()beforekill(). If the child already exited on its own (a fastcargo test, say), we don't want to error or bump the kill counter.try_waitwithOk(None)means "still running". -
wait()afterkill(). Without the reap, the process stays as a zombie in the process table untilwatch-execitself exits. On Linux this isn't catastrophic —initwill eventually adopt it — but why leave trash on the floor?
One deliberate scope decision: v1 only supports Child::kill(), which is SIGKILL on Unix. A correct SIGTERM-with-grace-period implementation requires libc::kill (or nix), per-platform signal number constants, and a separate grace timer. That's another two crates and about 150 lines of error-handling. I looked at the total dep graph and decided: no, not yet. The README says this honestly, and if someone files an issue saying their webserver needs graceful shutdown, that's v2.
Saying "we picked the simpler path and we're telling you" is worth a lot more than pretending you covered every edge case.
3. Glob semantics that match gitignore
The glob crate's MatchOptions has a flag called require_literal_separator. Off (the default), * matches across / boundaries, so *.rs matches src/main.rs. On, * stops at /, so you need **/*.rs to recurse. Gitignore, ripgrep, and most tool users expect the on behavior:
let opts = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
I initially left it off, because that's the struct default. A test called top_level_glob_does_not_match_subdirs immediately caught it — *.rs was matching src/main.rs and shouldn't have been. That test drove a one-line fix, and the fix also made the default ignore list (target/**, node_modules/**, .git/**) actually work the way anyone reading the README would expect.
One user-facing default ignore list:
pub const DEFAULT_IGNORES: &[&str] = &[
".git/**",
"node_modules/**",
"target/**",
".hg/**",
".svn/**",
];
Passing --no-default-ignores turns it off. Adding more with --ignore keeps the defaults. The ordering inside the matcher is: excludes checked first, includes second, because skipping the 99% case is cheaper than matching it.
4. Avoiding the infinite-loop-in-your-own-output problem
This is where the default ignore list earns its keep. Without target/** in the defaults, the first cargo test you run under watch-exec triggers a rerun as soon as rustc writes its first .rmeta, which triggers another rerun, which triggers another. I actually built this and watched it happen before adding the defaults. Ten seconds of self-DOS.
Once target/** is in the default excludes, cargo's own writes to target/ are silently filtered before they reach the debounce window's decision step. The filter runs against the repo-relative path, not the absolute one, because the absolute path depends on where the user ran the tool from:
pub fn relativize(root: &Path, path: &Path) -> PathBuf {
path.strip_prefix(root)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| path.to_path_buf())
}
Clean one-liner. The fallback — return the original path if it's outside the root — handles weird setups like watching /etc/ while running as /.
5. Testability: inject the event source
This was the part I was most worried about going in. notify surfaces filesystem events via a callback, which means most of its behavior is non-deterministic and impossible to unit-test directly. If you test your watcher by asking the real filesystem to emit events, you're writing integration tests that need tempfile, sleep statements, and retries, and you spend 40% of the suite's runtime in sleeps.
The fix was to keep notify at the edges and make the core pure. The core modules — matcher, runner, watcher (meaning the debounce loop) — take raw inputs and return raw outputs. matcher takes a path and returns bool. watcher::run_debounce_window takes a channel receiver and returns a DebounceOutcome. The runner takes a spawner trait, not a Command. Tests can wire a fake spawner:
struct FakeSpawner { log: Rc<RefCell<Vec<String>>>, counter: usize, ... }
impl Spawner for FakeSpawner {
type Handle = FakeChild;
fn spawn(&mut self, cmd: &[OsString]) -> io::Result<FakeChild> {
self.counter += 1;
self.log.borrow_mut().push(format!("spawn:{}", self.counter));
Ok(FakeChild { id: self.counter, ... })
}
}
And test the state machine directly:
#[test]
fn second_trigger_kills_previous_then_spawns() {
let (sp, log) = ...;
let mut r = Runner::new(sp, cmd(&["sleep", "60"]));
r.trigger().unwrap();
r.trigger().unwrap();
assert_eq!(r.spawn_count(), 2);
assert_eq!(r.kill_count(), 1);
assert_eq!(log.borrow().as_slice(), &[
"spawn:1:sleep 60",
"kill:1",
"spawn:2:sleep 60",
]);
}
That test runs in microseconds. No filesystem, no sleep, no flake. The only code path I didn't test this way is the notify-to-channel callback itself — that's tested by running the binary with --once, which exits before setting up the watcher at all.
Result: 45 tests total (37 unit, 8 CLI), run time under a quarter of a second inside the Docker builder. Every burst scenario, every edge of the matcher, every kill-old-then-spawn-new permutation exercised deterministically.
Tradeoffs — things I chose not to do
- SIGKILL only, no SIGTERM-with-grace. Two crates and 150 lines of signal plumbing I didn't want. Documented in README.
-
No regex mode. Globs are enough. Regex would add
regex(a ~1 MB dep) for maybe 2% of users. -
No filesystem-specific tricks. notify's
RecommendedWatcheralready picks inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows. I don't need to tune any of them, and every time someone tries to tune inotify they end up breaking a different OS. - No config file. The whole point was "drop into a Makefile target". A config file is a feature creep vector.
-
No
--restart-signal/--exit-on-first-failure/--notify-on-completion. These all exist incargo-watch; they all also make the tool marginally harder to reason about. Maybe v2.
Try it in 30 seconds
# Pull and run with Docker:
docker build -t watch-exec .
docker run --rm watch-exec --once -- echo hello
# → prints "hello", exits 0
docker run --rm watch-exec --once -- sh -c 'exit 3'
# → exits 1 (the command failed in --once mode)
Or install from source:
git clone https://github.com/sen-ltd/watch-exec
cd watch-exec
cargo install --path .
watch-exec --glob '**/*.rs' -- cargo test
Dependency count, rechecked
cargo tree --depth 1 at the top level:
watch-exec v0.1.0
├── clap v4
├── ctrlc v3
├── glob v0.3
└── notify v6
Four. That's it. The full transitive graph is about 60 crates — almost all of them come from notify's platform backends and clap's derive machinery — and the final stripped Alpine binary is 9.6 MB. For a language-agnostic file watcher that covers the four annoyances I listed at the top, I'll take that.
Closing
If you work across ecosystems and you've been installing cargo-watch, nodemon, watchmedo, and entr separately, give watch-exec a try. And if you've never looked at the notify crate before — it really is pleasant. The callback model is simple, the cross-platform story is transparent, and if you keep the watcher at the edge of your code, the rest becomes ordinary Rust.
Entry #146 in a 100+ portfolio series by SEN LLC. Feedback welcome.

Top comments (0)