diff --git a/README.md b/README.md index 648d914..3e5e61e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,12 @@ try inotify.addWatch(path: "/tmp/watched", mask: [.create, .modify]) // Consume events as they arrive for await event in await inotify.events { - print("Event at \(event.path): \(event.mask)") + switch event { + case .fileSystem(let change): + print("Event at \(change.path): \(change.mask)") + case .queueOverflow: + print("The kernel dropped events; rescan if you must not miss changes.") + } } ``` @@ -125,9 +130,9 @@ Convenience combinations: `.move` (`.movedFrom` + `.movedTo`), `.close` (`.close Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`. -Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.unmount`. +Kernel-only flags returned in events: `.isDir`, `.ignored`, `.unmount`. -When the kernel queue overflows, events are lost and a single event with `.queueOverflow` is delivered instead. It has no path and a watch descriptor of `-1`; rescan the watched directories if you must not miss changes. +When the kernel queue overflows, events are lost and `InotifyEvent.queueOverflow` is delivered instead of a file system event; rescan the watched directories if you must not miss changes. ## Removing a Watch diff --git a/Sources/Inotify/FileSystemEvent.swift b/Sources/Inotify/FileSystemEvent.swift new file mode 100644 index 0000000..332fd3f --- /dev/null +++ b/Sources/Inotify/FileSystemEvent.swift @@ -0,0 +1,32 @@ +import SystemPackage + +/// A change to a watched file or directory, as delivered by an ``Inotify`` +/// instance inside ``InotifyEvent/fileSystem(_:)``. +public struct FileSystemEvent: Sendable, Hashable, CustomStringConvertible { + public let watchDescriptor: Int32 + 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 = ["FileSystemEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""] + if cookie != 0 { parts.append("cookie: \(cookie)") } + return parts.joined(separator: ", ") + ")" + } +} + +extension FileSystemEvent { + public init(from rawEvent: RawInotifyEvent, inDirectory path: String) { + let dirPath = FilePath(path) + self.init( + watchDescriptor: rawEvent.watchDescriptor, + mask: rawEvent.mask, + cookie: rawEvent.cookie, + path: dirPath.appending(rawEvent.name), + synthesized: rawEvent.synthesized + ) + } +} diff --git a/Sources/Inotify/Inotify.docc/Inotify.md b/Sources/Inotify/Inotify.docc/Inotify.md index 3fe58b5..0d8c44a 100644 --- a/Sources/Inotify/Inotify.docc/Inotify.md +++ b/Sources/Inotify/Inotify.docc/Inotify.md @@ -4,14 +4,19 @@ Monitor filesystem events on Linux using modern Swift concurrency. ## Overview -The Inotify library wraps the Linux [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html) API in a Swift-native interface built around actors and async sequences. You create an ``Inotify/Inotify`` actor, add watches for the paths you care about, and iterate over the ``Inotify/Inotify/events`` property to receive ``InotifyEvent`` values as they occur. +The Inotify library wraps the Linux [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html) API in a Swift-native interface built around actors and async sequences. You create an ``Inotify/Inotify`` actor, add watches for the paths you care about, and iterate over the ``Inotify/Inotify/events`` property to receive ``InotifyEvent`` values as they occur. Most of them carry a ``FileSystemEvent`` describing a change to a watched item; the others tell you when the instance cannot deliver every change, such as after a kernel queue overflow. ```swift let inotify = try Inotify() try inotify.addWatch(path: "/tmp/inbox", mask: [.create, .modify]) for await event in await inotify.events { - print("\(event.mask) at \(event.path)") + switch event { + case .fileSystem(let change): + print("\(change.mask) at \(change.path)") + case .queueOverflow: + print("events were dropped, rescan") + } } ``` @@ -30,6 +35,7 @@ All public types conform to `Sendable`, so they can be safely passed across conc - ``Inotify/Inotify`` - ``InotifyEvent`` +- ``FileSystemEvent`` - ``InotifyEventMask`` ### Articles diff --git a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md index 76cc1d7..40b5451 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` 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. +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 ``FileSystemEvent/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 768b703..5f17c93 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -119,22 +119,22 @@ public actor Inotify { private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { if rawEvent.mask.contains(.queueOverflow) { - return InotifyEvent(from: rawEvent, inDirectory: "") + return .queueOverflow } guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil } guard !self.exclusions.excludes(rawEvent.name) else { return nil } - let event = InotifyEvent.init(from: rawEvent, inDirectory: path) + let event = FileSystemEvent(from: rawEvent, inDirectory: path) self.forgetWatchInCaseTheKernelRemovedIt(event) self.removeWatchesInCaseADirectoryLeftTheTree(event) await self.addWatchInCaseOfAutomaticSubtreeWatching(event) - return event + return .fileSystem(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) { + private func forgetWatchInCaseTheKernelRemovedIt(_ event: FileSystemEvent) { guard event.mask.contains(.ignored) else { return } self.watches.remove(forId: event.watchDescriptor) } @@ -142,7 +142,7 @@ public actor Inotify { /// 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) { + private func removeWatchesInCaseADirectoryLeftTheTree(_ event: FileSystemEvent) { 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) @@ -150,7 +150,7 @@ public actor Inotify { } } - private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async { + private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: FileSystemEvent) async { guard !event.synthesized, watches.isAutomaticSubtreeWatching(event.watchDescriptor), event.mask.contains(.isDir), diff --git a/Sources/Inotify/InotifyEvent.swift b/Sources/Inotify/InotifyEvent.swift index d81936a..da5eec2 100644 --- a/Sources/Inotify/InotifyEvent.swift +++ b/Sources/Inotify/InotifyEvent.swift @@ -1,37 +1,9 @@ -import SystemPackage - -/// A filesystem event delivered by an ``Inotify`` instance. -/// -/// When the kernel's event queue overflows, it drops events and reports a -/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``. -/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and -/// its ``path`` is empty. Consumers that must not miss changes should -/// rescan the watched trees when they receive one. -public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible { - public let watchDescriptor: Int32 - 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)\""] - if cookie != 0 { parts.append("cookie: \(cookie)") } - return parts.joined(separator: ", ") + ")" - } -} - -extension InotifyEvent { - public init(from rawEvent: RawInotifyEvent, inDirectory path: String) { - let dirPath = FilePath(path) - self.init( - watchDescriptor: rawEvent.watchDescriptor, - mask: rawEvent.mask, - cookie: rawEvent.cookie, - path: dirPath.appending(rawEvent.name), - synthesized: rawEvent.synthesized - ) - } +/// What an ``Inotify`` instance delivers: a change to a watched item, or a +/// condition that affects which changes it can deliver. +public enum InotifyEvent: Sendable, Hashable { + /// A change to a watched file or directory. + case fileSystem(FileSystemEvent) + /// The kernel's event queue was full, so it dropped events. Consumers + /// that must not miss changes should rescan the watched trees. + case queueOverflow } diff --git a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift index 213f104..c490cea 100644 --- a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift +++ b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift @@ -50,7 +50,7 @@ struct InotifyLimitTests { var received = 0 for await event in await watcher.events { received += 1 - if event.mask.contains(.queueOverflow) { return (event, received) } + if case .queueOverflow = event { return (event, received) } } return (nil, received) } @@ -65,9 +65,7 @@ struct InotifyLimitTests { overflowTask.cancel() let (overflow, received) = await overflowTask.value - #expect(overflow != nil, "Expected a queue overflow event after \(index) file creations and \(received) received events") - #expect(overflow?.watchDescriptor == -1) - #expect(overflow?.path == "") + #expect(overflow == .queueOverflow, "Expected a queue overflow event after \(index) file creations and \(received) received events") } } } diff --git a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift index 424443b..fb15c45 100644 --- a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift +++ b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift @@ -6,6 +6,7 @@ enum RecursivKind { case withAutomaticSubtreeWatching } +/// The file system events an instance delivers around `trigger`. func getEventsForTrigger( in dir: String, mask: InotifyEventMask, @@ -13,6 +14,27 @@ func getEventsForTrigger( exclude: [String] = [], excludePatterns: [String] = [], trigger: @escaping (String) async throws -> Void, +) async throws -> [FileSystemEvent] { + let events = try await getInotifyEventsForTrigger( + in: dir, + mask: mask, + recursive: recursive, + exclude: exclude, + excludePatterns: excludePatterns, + trigger: trigger + ) + return events.compactMap(\.fileSystemEvent) +} + +/// Everything an instance delivers around `trigger`, including the +/// events that are not about a file system item. +func getInotifyEventsForTrigger( + in dir: String, + mask: InotifyEventMask, + recursive: RecursivKind = .nonrecursive, + exclude: [String] = [], + excludePatterns: [String] = [], + trigger: @escaping (String) async throws -> Void, ) async throws -> [InotifyEvent] { let watcher = try Inotify() await watcher.exclude(names: exclude) @@ -41,3 +63,10 @@ func getEventsForTrigger( eventTask.cancel() return await eventTask.value } + +extension InotifyEvent { + var fileSystemEvent: FileSystemEvent? { + if case .fileSystem(let event) = self { return event } + return nil + } +}