fix(host): make unsubscribing a watch take effect at the drop, not after it

CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created
*after* the subscription was dropped still reached a consumer holding a clone of
the receiver.

Tearing the watcher down is not instantaneous. The OS backend runs its own
thread, and on Windows a `ReadDirectoryChangesW` completion can fire during
teardown, reach the event closure while `raw_tx` is still alive, and be
forwarded by a coalescer that has not yet noticed the disconnect. So "dropped"
meant "stops delivering shortly", which is not what the subscription promises —
and for a remote host it is the difference between releasing a server-side watch
and leaking one.

The handle now closes the delivery channel in its own `Drop`, before any of that
unwinds. Batches already queued stay readable — `close` stops sends, not
receives — which is the one thing a consumer racing its own drop may legitimately
still see, and exactly what the conformance test allows for.

Not this branch's bug: the change here is to git reads, not watches. But main is
flaky in the same family — it failed the sibling `watch_coalesces_within_window`
eleven hours ago and was hardened for that one — so this fixes the cause rather
than loosening the test.

Refs #239.
This commit is contained in:
l0ng-ai
2026-07-29 16:10:47 +08:00
parent 701be36f77
commit 2cdb416e8d
+22
View File
@@ -398,6 +398,27 @@ impl WatchedDirs {
/// A live local watch: the notify watcher plus the set it is following.
struct LocalWatch {
inner: Mutex<LocalWatchInner>,
/// The delivery end, kept solely so dropping this handle can close it.
///
/// Tearing the watcher down is not instantaneous — the OS backend has its
/// own thread, and on Windows a `ReadDirectoryChangesW` completion can fire
/// *during* teardown, reach the event closure while `raw_tx` is still
/// alive, and be forwarded by a coalescer that has not noticed the
/// disconnect yet. A consumer holding a clone of the receiver would then
/// see an event for a change made after it unsubscribed.
///
/// Closing the channel here makes "dropped" mean "no further batches" at
/// the instant of the drop, whatever the backend does afterwards. Batches
/// already queued stay readable — `close` stops sends, not receives — which
/// is the one thing a consumer racing its own drop may legitimately still
/// see.
batch_tx: smol::channel::Sender<Vec<PathBuf>>,
}
impl Drop for LocalWatch {
fn drop(&mut self) {
self.batch_tx.close();
}
}
struct LocalWatchInner {
@@ -463,6 +484,7 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R
watcher,
dirs: Arc::clone(&watched),
}),
batch_tx: batch_tx.clone(),
};
handle.set_dirs(dirs)?;