Code analysis only: confirmed by reading the code, not reproduced live. Filed for completeness after the v0.9.1 sync work. No user-visible symptom is known today.
Problem
The per-tab file watcher leaks when a tab's path changes while its watch() call is still in flight.
In src/hooks/useTabs.ts:445-490:
- Effect run N creates a slot for tab id T with a placeholder
unwatch: () => {} (line 461) and starts watch(pathA).
- The tab's path changes to B before that promise resolves (
openInActive reuses the tab id). The next effect run calls entry.unwatch(), which is still the no-op placeholder, deletes the map entry, and installs a fresh slot for the same id.
- When
watch(pathA) finally resolves, the guard !wantedIds.has(tab.id) || !watchers.has(tab.id) (line 483) is false on both counts, because the id is still wanted and the map now holds slot B. So slot.unwatch = unwatch (line 487) assigns the real unwatch onto the orphaned slot A object, which nothing references.
The pathA watcher (an FSEvents stream plus a debouncer thread) then lives until the process exits.
Impact
No incorrect content: the callback re-checks current.path !== tab.path against the captured old path and bails, so stale events cannot overwrite a tab. The cost is resource accumulation over a long session with frequent tab-path switching. FSEvents streams are a finite resource, and unbounded accumulation is the documented mechanism behind watches silently failing to start later in a process's life.
The race window is one watch IPC round-trip, so it needs fast successive path changes on the same tab to trigger, which is why it resists a deterministic repro.
Proposed fix
Track a generation/token per slot and have the resolved watch compare its own token against the map's current slot before adopting it; if the token does not match, immediately unwatch() and drop it. That closes the window without changing the effect's structure.
Problem
The per-tab file watcher leaks when a tab's path changes while its
watch()call is still in flight.In
src/hooks/useTabs.ts:445-490:unwatch: () => {}(line 461) and startswatch(pathA).openInActivereuses the tab id). The next effect run callsentry.unwatch(), which is still the no-op placeholder, deletes the map entry, and installs a fresh slot for the same id.watch(pathA)finally resolves, the guard!wantedIds.has(tab.id) || !watchers.has(tab.id)(line 483) is false on both counts, because the id is still wanted and the map now holds slot B. Soslot.unwatch = unwatch(line 487) assigns the real unwatch onto the orphaned slot A object, which nothing references.The pathA watcher (an FSEvents stream plus a debouncer thread) then lives until the process exits.
Impact
No incorrect content: the callback re-checks
current.path !== tab.pathagainst the captured old path and bails, so stale events cannot overwrite a tab. The cost is resource accumulation over a long session with frequent tab-path switching. FSEvents streams are a finite resource, and unbounded accumulation is the documented mechanism behind watches silently failing to start later in a process's life.The race window is one
watchIPC round-trip, so it needs fast successive path changes on the same tab to trigger, which is why it resists a deterministic repro.Proposed fix
Track a generation/token per slot and have the resolved
watchcompare its own token against the map's current slot before adopting it; if the token does not match, immediatelyunwatch()and drop it. That closes the window without changing the effect's structure.