From daeadbf91219c117bdbf4c9decb6b9cff6aa4045 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 15 Apr 2026 06:55:49 -1000 Subject: [PATCH] fix: patch 3 file-watcher bugs blocking merge 1. Task leak: add watch::channel shutdown signal to WatcherRuntime so the debounce loop exits when stop_file_watcher drops the runtime. Adds tokio 'macros' feature for select!. 2. ID collision: use crypto.randomUUID() for synthetic diff message IDs instead of seconds-precision timestamps that collide on rapid writes. 3. Channel switch cleanup: call stopFileWatcher in the useEffect cleanup so navigating away stops the backend watcher. Also converts the pointless dynamic import to a static import. --- desktop/src-tauri/Cargo.lock | 12 ++++++++++++ desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/file_watcher.rs | 12 +++++++++++- desktop/src/features/channels/ui/ChannelScreen.tsx | 10 +++++++--- desktop/src/features/messages/useLocalFileDiffs.ts | 2 +- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 189f9fcf5..45844f53f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -6263,9 +6263,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d1a16bc8e..dcb2fd1b2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" -tokio = { version = "1", features = ["fs", "sync", "rt"] } +tokio = { version = "1", features = ["fs", "sync", "rt", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" nostr = "0.37" diff --git a/desktop/src-tauri/src/file_watcher.rs b/desktop/src-tauri/src/file_watcher.rs index 57daef567..0be27533d 100644 --- a/desktop/src-tauri/src/file_watcher.rs +++ b/desktop/src-tauri/src/file_watcher.rs @@ -43,6 +43,8 @@ pub struct ProjectDirConfig { /// Runtime state for an active file watcher. struct WatcherRuntime { _watcher: RecommendedWatcher, + /// Dropping the sender signals the debounce loop to exit. + _shutdown_tx: tokio::sync::watch::Sender, } /// App-level state that holds all active file watchers. @@ -227,6 +229,9 @@ fn start_watcher( let project_dir_owned = project_dir.to_path_buf(); let snapshots_ref = Arc::clone(&fw_state.snapshots); + // Shutdown signal: when the sender is dropped the loop exits. + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + // Debounce state: track last event time per file. let pending: Arc>> = Arc::new(Mutex::new(HashMap::new())); let pending_clone = Arc::clone(&pending); @@ -240,7 +245,11 @@ fn start_watcher( tauri::async_runtime::spawn(async move { let debounce_delay = Duration::from_millis(500); loop { - tokio::time::sleep(Duration::from_millis(250)).await; + // Exit when the WatcherRuntime (and its shutdown_tx) is dropped. + tokio::select! { + _ = shutdown_rx.changed() => break, + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + } let ready_paths: Vec = { let mut pending_guard = match pending_clone.lock() { @@ -369,6 +378,7 @@ fn start_watcher( Ok(WatcherRuntime { _watcher: watcher, + _shutdown_tx: shutdown_tx, }) } diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 55ba0de87..278fec9f8 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -32,6 +32,11 @@ import { useLocalFileDiffs } from "@/features/messages/useLocalFileDiffs"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; +import { + getProjectDir, + startFileWatcher, + stopFileWatcher, +} from "@/shared/api/tauri"; import type { Channel, Identity, @@ -108,9 +113,6 @@ export function ChannelScreen({ let cancelled = false; void (async () => { try { - const { getProjectDir, startFileWatcher } = await import( - "@/shared/api/tauri" - ); const dir = await getProjectDir(activeChannelId); if (dir && !cancelled) { await startFileWatcher(activeChannelId); @@ -121,6 +123,8 @@ export function ChannelScreen({ })(); return () => { cancelled = true; + // Stop the backend watcher so we don't leak one per channel visited. + void stopFileWatcher(activeChannelId).catch(() => {}); }; }, [activeChannelId]); diff --git a/desktop/src/features/messages/useLocalFileDiffs.ts b/desktop/src/features/messages/useLocalFileDiffs.ts index e58115d89..05b40ebdd 100644 --- a/desktop/src/features/messages/useLocalFileDiffs.ts +++ b/desktop/src/features/messages/useLocalFileDiffs.ts @@ -52,7 +52,7 @@ export function useLocalFileDiffs(channelId: string | null) { return; } - const syntheticId = `local-diff-${payload.timestamp}-${payload.filePath}`; + const syntheticId = `local-diff-${crypto.randomUUID()}`; const message: TimelineMessage = { id: syntheticId,