TeraView hot-reload watcher forms an Arc cycle: every boot leaks a thread and an inotify instance (debug builds)
Summary
In debug builds, TeraView stores its hot-reload file watcher inside the Arc<Mutex<HotReloadingTeraEngine>> that the watcher's own event closure holds a clone of. That is a reference cycle, so the strong count never reaches zero and the engine is never dropped.
Each Hooks::boot therefore leaks one OS thread, one inotify instance, and several MB, permanently, for the lifetime of the process. Apps that boot once are unaffected in practice; integration test binaries that boot an app per test are hit hard.
Release builds are unaffected — there is no watcher.
The cycle
In src/controller/views/engine.rs:
let tera = std::sync::Arc::new(std::sync::Mutex::new(HotReloadingTeraEngine { ... })); // L129
let tera2 = tera.clone(); // L137
let mut watcher = notify::recommended_watcher(move |event| {
// ...
tera2.lock()...dirty = true; // closure owns tera2
})?;
tera.lock()...file_watcher = Box::new(watcher); // L194 — and now tera owns the watchertera → file_watcher → closure → tera2 → tera. Nothing ever drops.
Reproduction
An example that boots in a loop and prints RSS, thread count, and inotify fds:
#[tokio::main]
async fn main() {
for i in 1..=25 {
let boot = loco_rs::testing::prelude::boot_test::<App>().await.unwrap();
drop(boot);
println!("{i}\t{}MB\t{} threads\t{} inotify", rss_mb(), threads(), inotify_fds());
}
}(threads() counts /proc/self/task; inotify_fds() counts /proc/self/fd symlinks containing inotify.)
Observed on our app — dead linear, no plateau:
iter rss_mb delta threads inotify
1 82.7 +64.6 11 1
2 86.8 +4.1 12 2
3 90.9 +4.1 13 3
...
10 120.1 +4.2 20 10Two controls confirm the attribution:
- Skipping the view engine entirely (returning the router without the
ViewEngineextension) gives exactly +0.0MB, and a constant thread/inotify count, over the same loop. Nothing else in the app leaks. MALLOC_ARENA_MAX=1with aggressive trim thresholds changes nothing, so this is genuine retention rather than allocator behaviour.
The per-boot cost is larger than 4.2MB for anyone whose post_process closure captures something big. Ours registers a fluent_templates::ArcLoader, so each orphaned engine also pinned its own copy of the bundles and the real figure was ~23.5MB per boot.
Why this is worse than the megabytes suggest
fs.inotify.max_user_instances is per user, across all processes (commonly 128; 1024 on our CI box). A 318-test integration run leaks 318 inotify instances in a single process. Run a few suites concurrently — several worktrees, or a CI runner with parallel jobs — and the user hits the cap, at which point:
notify::recommended_watcher(...).map_err(|_| Error::string("error creating file watcher"))?starts failing, and the boot dies with error creating file watcher in whatever test happens to be running. The failure has no connection to its cause, and it is load-dependent, so it reads as flakiness.
For scale, on our suite a full serial run ended at 7.4GB RSS and 318 inotify instances. After working around it locally: 149MB and 1.
Suggested fix
Have the closure hold a Weak instead, so the watcher no longer keeps the engine alive:
let tera_weak = std::sync::Arc::downgrade(&tera);
let mut watcher = notify::recommended_watcher(move |event| {
// ...
let Some(tera) = tera_weak.upgrade() else { return }; // engine gone, nothing to mark
tera.lock().unwrap_or_else(|p| p.into_inner()).dirty = true;
})?;The Arc then solely owns the watcher, the watcher only weakly refers back, and dropping the last TeraView clone drops the engine, the watcher, its thread and its inotify instance.
Versions
Found on loco-rs 0.16.4; the cycle is unchanged on master at 2f67eb250e8f7d6a959fd2bbb45fabeabdb9039c (v1.1.0). Linux, notify via recommended_watcher (inotify backend).
Workaround for anyone hitting this
Build the view engine once per process behind a OnceLock in your ViewEngineInitializer, gated on #[cfg(debug_assertions)] so release is untouched. Worth gating rather than doing it unconditionally: in release TeraView holds the Tera engine directly, so cloning out of a cache would duplicate every compiled template for no benefit.
Source: loco-rs/loco