#1811·loco

TeraView hot-reload watcher forms an Arc cycle: every boot leaks a thread and an inotify instance (debug builds)

Author: joshua-koehlerCreated Aug 18, 2026Updated Sep 8, 2026

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:

rust
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 watcher

terafile_watcher → closure → tera2tera. Nothing ever drops.

Reproduction

An example that boots in a loop and prints RSS, thread count, and inotify fds:

rust
#[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      10

Two controls confirm the attribution:

  • Skipping the view engine entirely (returning the router without the ViewEngine extension) gives exactly +0.0MB, and a constant thread/inotify count, over the same loop. Nothing else in the app leaks.
  • MALLOC_ARENA_MAX=1 with 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:

rust
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:

rust
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.