文件监听第一次响应、之后永久沉默——Rust notify 的 inode 置换陷阱
在用 Tauri 写一个桌面工具时,我需要监听一个配置文件的变更,变化时通知前端重新加载。代码看上去无比简单——直接 watch 那个文件的绝对路径,然后在回调里 emit 事件。
结果:第一次用 VS Code 保存,前端收到了通知。然后……再也没有了。后续不管怎么保存,都是一片寂静。更诡异的是,用 echo x >> config.toml 追加内容,每次都能正常触发。
头一个直觉是防抖参数调小一点?改了没用。换成 notify-debouncer-mini?依然沉默。
现象
- 用
debouncer.watch(Path::new("/proj/config.toml"), RecursiveMode::NonRecursive)监听单个文件。 - 首次从编辑器(VS Code、vim、JetBrains 系)保存 → 回调触发一次。
- 之后无论怎么编辑保存 → 回调再也不触发。
echo x >> config.toml(原地追加)→ 每次都正常触发。
根因
这是一个反直觉的 OS 级陷阱:编辑器默认采用原子保存策略。
保存流程不是直接修改目标文件,而是:
- 写入一个临时文件(如
config.toml.tmp或.#config.toml)。 - 调用
rename()把临时文件覆盖到目标路径。
rename() 在文件系统层面会给目标路径分配一个全新的 inode,而原来的 inode 被解除链接。
OS 的文件监听机制(inotify / FSEvents / kqueue)绑定的是 inode,不是路径名。第一次保存前,watch 绑定的是原始 inode——那次触发是因为 rename 事件恰好命中了还存活的旧 inode;此后旧 inode 已经被释放,watch 名义上还活着,但它在监听一个已删除的文件,永远不会再有事件。
echo >> 是原地写入,不替换 inode,所以一直有效——这也正好反向确认了根因。
watcher.watch(Path::new("/abs/to/config.toml"), RecursiveMode::NonRecursive) 看起来精准,实际上在编辑器保存后就死了。调整防抖窗口、换 debouncer 实现都无济于事——问题在内核级别,不在库的参数。
正解
监听父目录,在回调里按文件名过滤。目录的 inode 是稳定的,rename() 产生的事件会以"目录内变更"的形式冒泡出来。
use std::time::Duration;
use notify_debouncer_full::notify::{RecursiveMode};
use notify_debouncer_full::{new_debouncer, DebounceEventResult};
let target_file = "config.toml";
let dir = "/proj"; // 父目录
let mut debouncer = new_debouncer(
Duration::from_millis(300),
None, // tick_rate: None = timeout / 4
move |res: DebounceEventResult| {
let Ok(events) = res else { return };
let hit = events.iter().any(|e| {
e.event.paths.iter()
.any(|p| p.file_name().is_some_and(|n| n == target_file))
});
if hit {
let _ = app.emit("config-changed", ());
}
},
)?;
// ✅ 监听父目录,NonRecursive 避免误入 node_modules / .git
debouncer.watch(std::path::Path::new(dir), RecursiveMode::NonRecursive)?;几个配套细节:
为什么用 notify-debouncer-full 而非 -mini? full 会把一次原子保存产生的 rename-from / rename-to 以及后续若干 fs 事件合并成一个逻辑事件,回调只触发一次。mini 只做时间窗口合并,一次保存可能触发多次回调。
Watcher 生命周期管理。 如果应用支持多处监听同一目录,建议用引用计数 Registry 管理 debouncer:
struct Entry { _debouncer: Debouncer<...>, refcount: usize }
// Mutex<HashMap<PathBuf, Entry>>
// watch() → refcount += 1(已存在则复用)
// unwatch() → refcount -= 1;到 0 则 remove(drop debouncer,自动停止线程)这样可以安全应对 React StrictMode 的 double-mount、以及多个组件监听同一目录的场景,不会产生孤儿 watcher 线程。
Tauri 事件名不能含 .,用 - 替代。
目录 inode 稳定,rename 事件冒泡到目录层,无论编辑器用何种保存策略都能持续触发。配合 notify-debouncer-full 的事件合并,每次保存只产生一次干净的回调。
快速判断清单
- "第一次触发,之后沉默" → inode 置换,不是防抖。
- "追加有效,Ctrl-S 无效" → 确认原子保存,立即上移监听层级。
- Watch 路径末尾是文件名 → 移到它的
dirname。
一句话外卖
永远监听目录再过滤文件名,而不是监听文件本身——编辑器的原子保存会悄无声息地杀死绑定在具体文件 inode 上的 watcher。