From e6ed2320873d4bfab354f898b189e5f81f23d348 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Sun, 13 Sep 2026 23:10:30 +0200 Subject: [PATCH] Drop watches of directories that leave the tree A directory moved out of a watched tree kept its kernel watches, so later changes inside it were reported under the old path. Its watches and those of its subdirectories are now removed on `MOVED_FROM`. Watches the kernel reports as `IGNORED` are forgotten as well, so a reused descriptor number cannot map to a stale path. --- README.md | 2 ++ .../Inotify.docc/WatchingDirectoryTrees.md | 2 ++ Sources/Inotify/Inotify.swift | 24 +++++++++++++++++- Sources/Inotify/InotifyWatchManager.swift | 8 ++++++ .../RecursiveEventTests.swift | 25 +++++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a92bcde..5a83eeb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ try await inotify.addWatchWithAutomaticSubtreeWatching( This is the most convenient option when you need full coverage of a growing directory tree. +When a watched directory is moved out of the tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path. + ## Excluding Items You can tell the `Inotify` actor to ignore certain file or directory names. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream: diff --git a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md index 318e670..4aea85c 100644 --- a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md +++ b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md @@ -33,6 +33,8 @@ let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching( Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears. +When a directory is moved out of the watched tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path. + ### Excluding Directories When watching large trees you often want to skip certain subdirectories entirely — version-control metadata, build artefacts, dependency caches, and so on. Call ``Inotify/Inotify/exclude(names:)`` **before** adding a recursive or automatic-subtree watch: diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 7b37977..24dc79b 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -99,8 +99,30 @@ public actor Inotify { guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil } guard !self.excludedItemNames.contains(rawEvent.name) else { return nil } let event = InotifyEvent.init(from: rawEvent, inDirectory: path) + self.forgetWatchInCaseTheKernelRemovedIt(event) + self.removeWatchesInCaseADirectoryLeftTheTree(event) await self.addWatchInCaseOfAutomaticSubtreeWatching(event) - return InotifyEvent.init(from: rawEvent, inDirectory: path) + return event + } + + /// The kernel reports `IN_IGNORED` once a watch is gone, whether it was + /// removed explicitly or because its item was deleted or unmounted. + /// Forgetting it keeps a reused descriptor number from mapping to a + /// stale path. + private func forgetWatchInCaseTheKernelRemovedIt(_ event: InotifyEvent) { + guard event.mask.contains(.ignored) else { return } + self.watches.remove(forId: event.watchDescriptor) + } + + /// A directory moved out of a watched tree keeps its kernel watches, + /// which would then report events under the old path. Those watches + /// are removed instead. + private func removeWatchesInCaseADirectoryLeftTheTree(_ event: InotifyEvent) { + guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return } + for wd in self.watches.descriptors(under: event.path.string) { + inotify_rm_watch(self.fd, wd) + self.watches.remove(forId: wd) + } } private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async { diff --git a/Sources/Inotify/InotifyWatchManager.swift b/Sources/Inotify/InotifyWatchManager.swift index db69f56..65c8c1b 100644 --- a/Sources/Inotify/InotifyWatchManager.swift +++ b/Sources/Inotify/InotifyWatchManager.swift @@ -36,6 +36,14 @@ struct InotifyWatchManager { return self.watchPaths[watchDescriptor] } + /// The descriptors of the watch on `path` itself and of every watch below it. + func descriptors(under path: String) -> [CInt] { + let prefix = path.hasSuffix("/") ? path : path + "/" + return self.watchPaths + .filter { $0.value == path || $0.value.hasPrefix(prefix) } + .map(\.key) + } + func mask(forId watchDescriptor: CInt) -> InotifyEventMask? { return self.watchMasks[watchDescriptor] } diff --git a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift index 7965a34..34b3851 100644 --- a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift +++ b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift @@ -58,4 +58,29 @@ struct RecursiveEventTests { #expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)") } } + + @Test func stopsReportingForDirectoriesMovedOutOfTheWatchedTree() async throws { + try await withTempDir { dir in + let root = "\(dir)/Root" + let outside = "\(dir)/Outside" + let movedSource = "\(root)/Moved" + let movedDestination = "\(outside)/Moved" + let filename = "created-after-move.txt" + try FileManager.default.createDirectory(atPath: movedSource, withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: outside, withIntermediateDirectories: true) + + let events = try await getEventsForTrigger( + in: root, + mask: [.create, .movedFrom], + recursive: .withAutomaticSubtreeWatching + ) { _ in + try FileManager.default.moveItem(atPath: movedSource, toPath: movedDestination) + try await Task.sleep(for: .milliseconds(400)) + try createFile(at: "\(movedDestination)/\(filename)", contents: "hello") + } + + let staleEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename } + #expect(staleEvent == nil, "Did not expect CREATE for '\(filename)' after its directory left the tree, got: \(events)") + } + } }