デバウンス付きファイルウォッチャー
書き込みマーカーによりアプリの書き込みと外部変更を区別する、汎用デバウンス付きファイルウォッチャーパターン。
問題
ファイルシステムウォッチャー(notify クレート経由)はイベントを高速かつ重複して発火する。単一のファイル保存で、数ミリ秒以内に複数の Create および Modify イベントがトリガーされることがある。デバウンスなしでは、アプリはフロントエンドに重複イベントを送出し、不要な再レンダリングや潜在的な競合状態を引き起こす。
加えて、自分のアプリによる変更と外部の変更(例:ユーザーがテキストエディタで編集)を区別する必要がある。この区別がなければ、アプリからファイルを書き込むとウォッチャーイベントがトリガーされ、「外部変更」としてフロントエンドに跳ね返ってしまう。
デバウンス付き監視ループ
各ウォッチャーにデバウンスロジックを重複して実装するのではなく、汎用ヘルパーとして抽出する:
use std::sync::mpsc;
use std::time::{Duration, Instant};
use notify::{Event, EventKind};
/// Generic debounced watch loop.
///
/// - `rx`: receives raw filesystem events
/// - `debounce`: how long to wait after the last event before emitting
/// - `matches_event`: returns `Some(T)` if the event is relevant
/// - `on_emit`: called with the matched value after the debounce period
fn debounced_watch_loop<T, F, E>(
rx: mpsc::Receiver<Event>,
debounce: Duration,
matches_event: F,
on_emit: E,
) where
T: Send,
F: Fn(&Event) -> Option<T>,
E: Fn(T),
{
let mut pending: Option<T> = None;
let mut last_event_time = Instant::now();
loop {
match rx.recv_timeout(debounce) {
Ok(event) => {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {}
_ => continue,
}
if let Some(val) = matches_event(&event) {
pending = Some(val);
last_event_time = Instant::now();
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if pending.is_some()
&& last_event_time.elapsed() >= debounce
{
if let Some(val) = pending.take() {
on_emit(val);
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
💡 Tip
matches_event クロージャは Some(T) を返すことで、抽出されたデータ(ファイル名など)を on_emit コールバックに運ぶ。共有可変状態の代わりに戻り値を使用することで、キャプチャされたすべての変数が thread::spawn クロージャ内で Send 安全に保たれる。
デバウンスの動作原理
重要な動作:新しいイベントが来るたびにタイマーがリセットされる。コールバックは、新しいイベントがないままデバウンス期間が経過した後にのみ発火する。これにより、高速なイベントバーストが自然に単一の発行にまとめられる。
ヘルパーの使用
メッセージディレクトリウォッチャー
ディレクトリ内の .md ファイルの変更を 300ms のデバウンスで監視する:
pub fn start_messages_watcher(state: &AppState, app: &AppHandle) {
let project_root = match state.project_root.lock() {
Ok(pr) => pr.clone(),
Err(_) => return,
};
let archives_dir = Path::new(&project_root).join("archives");
if !archives_dir.is_dir() {
return;
}
let app_handle = app.clone();
let (tx, rx) = mpsc::channel::<Event>();
let mut watcher = notify::recommended_watcher(
move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
},
).expect("watcher creation failed");
watcher
.watch(&archives_dir, RecursiveMode::NonRecursive)
.expect("watcher start failed");
std::thread::spawn(move || {
debounced_watch_loop(
rx,
Duration::from_millis(300),
|event| {
for path in &event.paths {
if let Some(fname) =
path.file_name().and_then(|n| n.to_str())
{
if fname.ends_with(".md")
&& !fname.starts_with("index")
{
return Some(fname.to_string());
}
}
}
None
},
|filename| {
let _ = app_handle.emit(
"messages:changed",
MessagesChangedPayload { filename },
);
},
);
});
// Store watcher handle to keep it alive
if let Ok(mut ws) = state.watchers.lock() {
ws.messages_watcher = Some(watcher);
}
}
⚠️ Warning
ウォッチャーハンドルを保存する(例:AppState 内に)必要がある。ドロップされるとウォッチャーは停止する。ウォッチャーのドロップはチャネルも切断し、Disconnected を通じて debounced_watch_loop をクリーンに終了させる。
書き込みマーカー:アプリの書き込みと外部変更の区別
コンテンツベースのマーカー(テキストファイル用)
ドラフトのようなテキストファイルでは、ファイルの内容をアプリが最後に書き込んだ内容と比較する:
// WatcherState stores the last written content
pub struct WatcherState {
/// Last content written by the app to draft.md
pub draft_write_content: Arc<Mutex<Option<String>>>,
// ...
}
アプリが書き込んだ後、内容を記録する:
pub fn mark_draft_write(
state: &AppState,
written_content: Option<&str>,
) {
if let Ok(ws) = state.watchers.lock() {
if let Ok(mut val) = ws.draft_write_content.lock() {
*val = written_content.map(|s| s.to_string());
}
}
}
ウォッチャーコールバック内で、保存された内容と比較する:
|_| {
if let Ok(content) = fs::read_to_string(&draft_path) {
let stored = content_arc
.lock()
.unwrap_or_else(|e| e.into_inner());
let is_external = stored.as_deref() != Some(&*content);
drop(stored);
if is_external {
let _ = app_handle.emit(
"draft:externalChange",
DraftExternalChangePayload { content },
);
}
}
}
📝 Note
コンテンツ比較はテキストファイルにおいて mtime 比較よりも堅牢である。同じ内容の 2 回の高速書き込みは、mtime が変更されたとしても、外部変更ではないと正しく識別される。
mtime ベースのマーカー(バイナリファイルや大きなファイル用)
コンテンツ比較が高コストなファイルでは、mtime ベースのマーカーを使用する:
pub struct WatcherState {
/// Shared mtime guard for pin write detection.
pub pin_write_mtime: Arc<Mutex<u64>>,
/// The absolute path of the currently-watched pin file.
pub watched_pin_path: Option<String>,
// ...
}
書き込み後、ファイルの mtime を記録する:
pub fn mark_pin_write(state: &AppState, path: &str) {
let ws = match state.watchers.lock() {
Ok(ws) => ws,
Err(_) => return,
};
// Only update if the written path matches what we're watching
if ws.watched_pin_path.as_deref() != Some(path) {
return;
}
let mtime = mtime_ms(Path::new(path));
if let Ok(mut val) = ws.pin_write_mtime.lock() {
*val = mtime;
}
}
ウォッチャーコールバック内で、現在の mtime と保存されたものを比較する:
|_| {
let current_mtime = mtime_ms(&file_path);
let last = *mtime_arc
.lock()
.unwrap_or_else(|e| e.into_inner());
if current_mtime > last {
let _ = app_handle.emit(
"pins:fileChanged",
PinFileChangedPayload { entry_path },
);
}
}
Option<T> による Send 安全性
debounced_watch_loop は pending の値に Option<T> を使用し、T は Send である必要がある。ループは thread::spawn 内で実行されるため、これは重要である。
matches_event クロージャは Option<T> を返すため、抽出するすべてのデータは所有された Send 安全なものでなければならない。共有状態を変更するのではなく値を返すことで、クロージャは追加の同期の問題なく Arc<Mutex<...>> 値への参照をキャプチャできる。
// This works because String is Send
debounced_watch_loop(
rx,
debounce,
|event| -> Option<String> {
// Extract and return owned data
Some(filename.to_string())
},
|filename: String| {
// Consume the owned data
},
);
ウォッチャーの停止
ウォッチャーハンドルをドロップして監視を停止する。これによりチャネルが切断され、バックグラウンドスレッドが終了する:
pub fn stop_watchers(state: &AppState) {
if let Ok(mut ws) = state.watchers.lock() {
ws.messages_watcher = None; // Drop stops watching
ws.draft_watcher = None;
ws.pin_watcher = None;
ws.watched_pin_path = None;
// Reset guards
if let Ok(mut m) = ws.draft_write_content.lock() {
*m = None;
}
if let Ok(mut m) = ws.pin_write_mtime.lock() {
*m = 0;
}
}
}
重要なポイント
- 汎用デバウンスループを使用する — 各ウォッチャーにデバウンスロジックを重複させない
- テキストファイルにはコンテンツ比較 — 自己書き込みの検出において mtime より正確
- 大きなファイル/バイナリファイルには mtime 比較 — コンテンツ比較より低コスト
- 書き込み直後にマーカーを記録する —
fs::writeの直後にmark_*_writeを呼び出す - ウォッチャーハンドルを保存する — ドロップするとウォッチャーが停止する
- 共有ガードには
Arc<Mutex<>>を使用する — ウォッチャースレッドとコマンドハンドラーの両方が同じマーカーデータにアクセスできる