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.
This commit is contained in:
Taylor Ho
2026-04-15 06:55:49 -10:00
parent 22e251991a
commit daeadbf912
5 changed files with 32 additions and 6 deletions
+12
View File
@@ -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"
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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<bool>,
}
/// 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<Mutex<HashMap<PathBuf, Instant>>> = 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<PathBuf> = {
let mut pending_guard = match pending_clone.lock() {
@@ -369,6 +378,7 @@ fn start_watcher(
Ok(WatcherRuntime {
_watcher: watcher,
_shutdown_tx: shutdown_tx,
})
}
@@ -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]);
@@ -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,