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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user