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.
|
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
|
## 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:
|
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.
|
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
|
### 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:
|
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 let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
|
||||||
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
|
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
|
||||||
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
||||||
|
self.forgetWatchInCaseTheKernelRemovedIt(event)
|
||||||
|
self.removeWatchesInCaseADirectoryLeftTheTree(event)
|
||||||
await self.addWatchInCaseOfAutomaticSubtreeWatching(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 {
|
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ struct InotifyWatchManager {
|
|||||||
return self.watchPaths[watchDescriptor]
|
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? {
|
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
|
||||||
return self.watchMasks[watchDescriptor]
|
return self.watchMasks[watchDescriptor]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,29 @@ struct RecursiveEventTests {
|
|||||||
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
#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