diff --git a/README.md b/README.md index 5a83eeb..442a817 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched. ### Automatic Subtree Watching -`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` events with the `isDir` flag. Whenever a new subdirectory appears, a watch is installed on it automatically: +`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` and `MOVED_TO` events with the `isDir` flag. Whenever a subdirectory appears, whether created or moved in, a watch is installed on it and on its subdirectories automatically: ```swift try await inotify.addWatchWithAutomaticSubtreeWatching( @@ -75,6 +75,8 @@ try await inotify.addWatchWithAutomaticSubtreeWatching( This is the most convenient option when you need full coverage of a growing directory tree. +Items that already exist inside a directory that appears this way never produce kernel events. The library reports them as if they had just appeared, using the same kind of event (`CREATE` or `MOVED_TO`), with `synthesized` set to `true`. A synthesized event may duplicate a kernel event for the same item, so consumers that act on events should tolerate seeing an item twice. + 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 diff --git a/Sources/Inotify/DirectoryResolver.swift b/Sources/Inotify/DirectoryResolver.swift index b96bba0..8646d6b 100644 --- a/Sources/Inotify/DirectoryResolver.swift +++ b/Sources/Inotify/DirectoryResolver.swift @@ -23,6 +23,19 @@ public struct DirectoryResolver { return resolved } + /// The direct children of `directory`, without the excluded names. + static func entries(of directory: FilePath, excluding itemNames: Set = []) async throws -> [(name: String, isDirectory: Bool)] { + let directoryHandle = try await fileManager.openDirectory(atPath: directory) + var entries: [(name: String, isDirectory: Bool)] = [] + for try await childContent in directoryHandle.listContents() { + guard let name = childContent.path.lastComponent?.string else { continue } + guard !itemNames.contains(name) else { continue } + entries.append((name: name, isDirectory: childContent.type == .directory)) + } + try await directoryHandle.close() + return entries + } + private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws { let directoryHandle = try await fileManager.openDirectory(atPath: path) for try await childContent in directoryHandle.listContents() { diff --git a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md index 4aea85c..83b5044 100644 --- a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md +++ b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md @@ -31,7 +31,7 @@ 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` and `MOVED_TO` events carrying the ``InotifyEventMask/isDir`` flag and installs new watches with the same mask on the subdirectory and its subtree whenever one appears. Items that already exist inside such a subdirectory are reported with ``InotifyEvent/synthesized`` set to `true`, since the kernel never produces events for them; a synthesized event may duplicate a kernel event for the same item. 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. diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 24dc79b..f743239 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -1,5 +1,6 @@ import Dispatch import CInotify +import SystemPackage public actor Inotify { private let fd: CInt @@ -7,6 +8,7 @@ public actor Inotify { private var watches = InotifyWatchManager() private nonisolated(unsafe) let eventReader: any DispatchSourceRead private nonisolated let eventStream: AsyncStream + private nonisolated let continuation: AsyncStream.Continuation public nonisolated var events: AsyncCompactMapSequence, InotifyEvent> { self.eventStream.compactMap(self.transform(_:)) } @@ -25,7 +27,7 @@ public actor Inotify { guard self.fd >= 0 else { throw InotifyError.initFailed(errno: cinotify_get_errno()) } - (self.eventReader, self.eventStream) = Self.createEventReader( + (self.eventReader, self.eventStream, self.continuation) = Self.createEventReader( forFileDescriptor: fd, bufferingPolicy: bufferingPolicy ) @@ -126,20 +128,48 @@ public actor Inotify { } private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async { - guard watches.isAutomaticSubtreeWatching(event.watchDescriptor), - event.mask.contains(.create), - event.mask.contains(.isDir) else { + guard !event.synthesized, + watches.isAutomaticSubtreeWatching(event.watchDescriptor), + event.mask.contains(.isDir), + let kind = Self.subtreeTrigger(in: event.mask) else { return } guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return } - let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) + guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return } + await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie) + } + + private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? { + if mask.contains(.create) { return .create } + if mask.contains(.movedTo) { return .movedTo } + return nil + } + + /// Items that already exist when a directory becomes watched never + /// produce kernel events, so they are reported as if they had just + /// appeared, marked as synthesized. + private func synthesizeEvents(forContentOfWatches wds: [CInt], kind: InotifyEventMask, cookie: UInt32) async { + for wd in wds { + guard let directory = self.watches.path(forId: wd) else { continue } + guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.excludedItemNames) else { continue } + for entry in entries { + let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind + self.continuation.yield(RawInotifyEvent( + watchDescriptor: wd, + mask: mask, + cookie: cookie, + name: entry.name, + synthesized: true + )) + } + } } private static func createEventReader( forFileDescriptor fd: CInt, bufferingPolicy: AsyncStream.Continuation.BufferingPolicy - ) -> (any DispatchSourceRead, AsyncStream) { + ) -> (any DispatchSourceRead, AsyncStream, AsyncStream.Continuation) { let (stream, continuation) = AsyncStream.makeStream( of: RawInotifyEvent.self, bufferingPolicy: bufferingPolicy @@ -161,6 +191,6 @@ public actor Inotify { } reader.activate() - return (reader, stream) + return (reader, stream, continuation) } } diff --git a/Sources/Inotify/InotifyEvent.swift b/Sources/Inotify/InotifyEvent.swift index b0ee0b2..d81936a 100644 --- a/Sources/Inotify/InotifyEvent.swift +++ b/Sources/Inotify/InotifyEvent.swift @@ -12,6 +12,9 @@ public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible { public let mask: InotifyEventMask public let cookie: UInt32 public let path: FilePath + /// Whether the event was produced by the library for an item that already + /// existed when its directory became watched, rather than by the kernel. + public let synthesized: Bool public var description: String { var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""] @@ -27,7 +30,8 @@ extension InotifyEvent { watchDescriptor: rawEvent.watchDescriptor, mask: rawEvent.mask, cookie: rawEvent.cookie, - path: dirPath.appending(rawEvent.name) + path: dirPath.appending(rawEvent.name), + synthesized: rawEvent.synthesized ) } } diff --git a/Sources/Inotify/InotifyEventParser.swift b/Sources/Inotify/InotifyEventParser.swift index ee34418..093a8c9 100644 --- a/Sources/Inotify/InotifyEventParser.swift +++ b/Sources/Inotify/InotifyEventParser.swift @@ -31,7 +31,8 @@ struct InotifyEventParser { watchDescriptor: rawEvent.wd, mask: InotifyEventMask(rawValue: rawEvent.mask), cookie: rawEvent.cookie, - name: Self.extractName(from: eventPointer, nameLength: rawEvent.len) + name: Self.extractName(from: eventPointer, nameLength: rawEvent.len), + synthesized: false )) offset += Self.eventSize(nameLength: rawEvent.len) diff --git a/Sources/Inotify/RawInotifyEvent.swift b/Sources/Inotify/RawInotifyEvent.swift index 4fadccd..c8f4c87 100644 --- a/Sources/Inotify/RawInotifyEvent.swift +++ b/Sources/Inotify/RawInotifyEvent.swift @@ -3,6 +3,9 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible { public let mask: InotifyEventMask public let cookie: UInt32 public let name: String + /// Whether the event was produced by the library for an item that already + /// existed when its directory became watched, rather than by the kernel. + public let synthesized: Bool public var description: String { var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""] diff --git a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift index 34b3851..ba0e282 100644 --- a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift +++ b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift @@ -83,4 +83,41 @@ struct RecursiveEventTests { #expect(staleEvent == nil, "Did not expect CREATE for '\(filename)' after its directory left the tree, got: \(events)") } } + + @Test func watchesAndReportsContentOfDirectoriesMovedIntoTheTree() async throws { + try await withTempDir { dir in + let root = "\(dir)/Root" + let treeSource = "\(dir)/Outside/Tree" + let treeDestination = "\(root)/Tree" + try FileManager.default.createDirectory(atPath: "\(treeSource)/Sub", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + try createFile(at: "\(treeSource)/existing.txt", contents: "hello") + try createFile(at: "\(treeSource)/Sub/nested.txt", contents: "hello") + + let events = try await getEventsForTrigger( + in: root, + mask: [.create, .movedTo], + recursive: .withAutomaticSubtreeWatching + ) { _ in + try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination) + try await Task.sleep(for: .milliseconds(400)) + try createFile(at: "\(treeDestination)/Sub/created-after-move.txt", contents: "hello") + } + + let movedIn = events.first { $0.mask.contains(.movedTo) && $0.mask.contains(.isDir) && $0.path.string == treeDestination } + #expect(movedIn != nil, "Expected MOVED_TO for '\(treeDestination)', got: \(events)") + + let existing = events.first { $0.synthesized && $0.mask.contains(.movedTo) && $0.path.string == "\(treeDestination)/existing.txt" } + #expect(existing != nil, "Expected a synthesized MOVED_TO for the existing file, got: \(events)") + + let subdirectory = events.first { $0.synthesized && $0.mask.contains(.isDir) && $0.path.string == "\(treeDestination)/Sub" } + #expect(subdirectory != nil, "Expected a synthesized MOVED_TO for the existing subdirectory, got: \(events)") + + let nested = events.first { $0.synthesized && $0.path.string == "\(treeDestination)/Sub/nested.txt" } + #expect(nested != nil, "Expected a synthesized MOVED_TO for the nested file, got: \(events)") + + let createdAfterMove = events.first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == "\(treeDestination)/Sub/created-after-move.txt" } + #expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)") + } + } }