diff --git a/README.md b/README.md index 3e5e61e..3571837 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ for await event in await inotify.events { print("Event at \(change.path): \(change.mask)") case .queueOverflow: print("The kernel dropped events; rescan if you must not miss changes.") + case .watchFailed(let path, let error): + print("Changes below \(path) go unreported: \(error)") } } ``` @@ -84,6 +86,8 @@ Items that already exist inside a directory that appears this way never produce 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. +Extending the watch to a new directory can fail, typically because the user's watch limit (`fs.inotify.max_user_watches`) is reached or the directory is not readable. The library then watches what it can and delivers `InotifyEvent.watchFailed(path:error:)` for each directory it could not watch, so changes below that path are known to go unreported. A directory that vanished before it could be watched is not reported. The explicit `addRecursiveWatch` and `addWatchWithAutomaticSubtreeWatching` calls, by contrast, either watch the whole tree or throw and leave no watch behind. + ## Excluding Items You can tell the `Inotify` actor to ignore certain file or directory names, either exactly or by a shell pattern. Excluded items are skipped during recursive directory resolution (so no watch is installed on them), never get a watch when they appear later, and are silently dropped from the event stream: diff --git a/Sources/Inotify/DirectoryResolver.swift b/Sources/Inotify/DirectoryResolver.swift index 05bacc2..8de33ce 100644 --- a/Sources/Inotify/DirectoryResolver.swift +++ b/Sources/Inotify/DirectoryResolver.swift @@ -1,3 +1,4 @@ +import CInotify import _NIOFileSystem public struct DirectoryResolver { @@ -19,6 +20,45 @@ public struct DirectoryResolver { return resolved } + /// Resolves `path` like ``resolve(_:excluding:)``, but a directory that + /// cannot be listed is recorded with its errno and skipped together with + /// its subtree, instead of failing the whole resolution. + static func resolveTolerantly(_ path: FilePath, excluding exclusions: ExclusionList) async -> TolerantResolution { + var resolution = TolerantResolution() + await collectDirectories(at: path, excluding: exclusions, into: &resolution) + return resolution + } + + private static func collectDirectories(at path: FilePath, excluding exclusions: ExclusionList, into resolution: inout TolerantResolution) async { + let subdirectories: [FilePath] + do { + subdirectories = try await entries(of: path, excluding: exclusions) + .filter(\.isDirectory) + .map { path.appending($0.name) } + } catch { + resolution.unreadable.append((path: path, errno: errno(of: error))) + return + } + resolution.directories.append(path) + for subdirectory in subdirectories { + await collectDirectories(at: subdirectory, excluding: exclusions, into: &resolution) + } + } + + /// The errno behind a file system error; the error's code when the + /// system call is unknown. + private static func errno(of error: any Error) -> Int32 { + guard let fileSystemError = error as? FileSystemError else { return EIO } + if let systemCall = fileSystemError.cause as? FileSystemError.SystemCallError { + return systemCall.errno.rawValue + } + return switch fileSystemError.code { + case .permissionDenied: EACCES + case .notFound: ENOENT + default: EIO + } + } + /// The direct children of `directory`, without the excluded items. static func entries(of directory: FilePath, excluding exclusions: ExclusionList = ExclusionList()) async throws -> [(name: String, isDirectory: Bool)] { let directoryHandle = try await fileManager.openDirectory(atPath: directory) @@ -45,3 +85,9 @@ public struct DirectoryResolver { try await directoryHandle.close() } } + +struct TolerantResolution { + /// The directories that could be listed, each before its subdirectories. + var directories: [FilePath] = [] + var unreadable: [(path: FilePath, errno: Int32)] = [] +} diff --git a/Sources/Inotify/Inotify.docc/Inotify.md b/Sources/Inotify/Inotify.docc/Inotify.md index 0d8c44a..cce5130 100644 --- a/Sources/Inotify/Inotify.docc/Inotify.md +++ b/Sources/Inotify/Inotify.docc/Inotify.md @@ -4,7 +4,7 @@ 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. 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. +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, after a kernel queue overflow or when a new directory of a watched tree could not be watched. ```swift let inotify = try Inotify() @@ -16,6 +16,8 @@ for await event in await inotify.events { print("\(change.mask) at \(change.path)") case .queueOverflow: print("events were dropped, rescan") + case .watchFailed(let path, let error): + print("changes below \(path) go unreported: \(error)") } } ``` diff --git a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md index 360f428..2c3221d 100644 --- a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md +++ b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md @@ -35,6 +35,27 @@ Internally this listens for `CREATE` and `MOVED_TO` events carrying the ``Inotif 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. +#### When a New Directory Cannot Be Watched + +Extending the watch can fail, most often because the user's watch limit, `fs.inotify.max_user_watches`, is reached, or because the process may not read the new directory. No call of yours is running at that moment, so the library watches what it can and reports every directory it could not watch as ``InotifyEvent/watchFailed(path:error:)``, after the event of the directory whose appearance triggered the extension: + +```swift +for await event in await inotify.events { + switch event { + case .fileSystem(let change): + handle(change) + case .queueOverflow: + rescan() + case .watchFailed(let path, let error): + log("changes below \(path) go unreported: \(error)") + } +} +``` + +A reached limit ends the extension, since nothing more can be watched until watches are freed, so only the first directory that failed is reported. An unreadable directory is reported and skipped together with its subtree, while its readable siblings are watched. A directory that vanished before it could be watched is not reported, because its removal arrives as an event of its own. + +The explicit calls above behave differently: they either watch the whole tree or throw, and a call that throws removes the watches it had added. + ### 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:)`` or ``Inotify/Inotify/exclude(patterns:)`` **before** adding a recursive or automatic-subtree watch: diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 2947097..432290c 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -7,9 +7,9 @@ public actor Inotify { private var exclusions = ExclusionList() 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> { + private nonisolated let eventStream: AsyncStream + private nonisolated let continuation: AsyncStream.Continuation + public nonisolated var events: some AsyncSequence { self.eventStream.compactMap(self.transform(_:)) } @@ -22,14 +22,14 @@ public actor Inotify { /// reading ``events``. The default `.unbounded` keeps every event, so a /// burst of changes is never lost; a bounded policy trades memory for /// dropped events. - public init(bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) throws { + public init(bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) throws { self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC)) guard self.fd >= 0 else { throw InotifyError.initFailed(errno: cinotify_get_errno()) } (self.eventReader, self.eventStream, self.continuation) = Self.createEventReader( forFileDescriptor: fd, - bufferingPolicy: bufferingPolicy + bufferingPolicy: Self.bufferedPolicy(for: bufferingPolicy) ) } @@ -75,7 +75,7 @@ public actor Inotify { } @discardableResult - public func addWatch(path: String, mask: InotifyEventMask) throws -> CInt { + public func addWatch(path: String, mask: InotifyEventMask) throws(InotifyError) -> CInt { let wd = inotify_add_watch(self.fd, path, mask.rawValue) guard wd >= 0 else { throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno()) @@ -108,7 +108,7 @@ public actor Inotify { return wds } - public func removeWatch(_ wd: CInt) throws { + public func removeWatch(_ wd: CInt) throws(InotifyError) { guard inotify_rm_watch(self.fd, wd) == 0 else { throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno()) } @@ -123,6 +123,13 @@ public actor Inotify { self.eventReader.cancel() } + private func transform(_ buffered: BufferedEvent) async -> InotifyEvent? { + switch buffered { + case .event(let event): event + case .raw(let rawEvent): await transform(rawEvent) + } + } + private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { if rawEvent.mask.contains(.queueOverflow) { return .queueOverflow @@ -166,15 +173,44 @@ public actor Inotify { guard !event.synthesized, watches.isAutomaticSubtreeWatching(event.watchDescriptor), event.mask.contains(.isDir), - let kind = Self.subtreeTrigger(in: event.mask) else { + let kind = Self.subtreeTrigger(in: event.mask), + let mask = self.watches.mask(forId: event.watchDescriptor) else { return } - guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return } - guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return } + let wds = await self.extendWatches(to: event.path, mask: mask) + watches.enableAutomaticSubtreeWatching(forIds: wds) await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie) } + /// Watches what it can of the tree at `path` and reports the rest as + /// ``InotifyEvent/watchFailed(path:error:)``. No consumer can catch an + /// error here, so the events are the only way to tell them. + private func extendWatches(to path: FilePath, mask: InotifyEventMask) async -> [CInt] { + let resolution = await DirectoryResolver.resolveTolerantly(path, excluding: self.exclusions) + for (unreadable, errno) in resolution.unreadable where errno != ENOENT { + self.report(.listDirectoryFailed(path: unreadable.string, errno: errno), for: unreadable) + } + var wds: [CInt] = [] + for directory in resolution.directories { + do { + wds.append(try self.addWatch(path: directory.string, mask: mask)) + } catch .addWatchFailed(_, let errno) where errno == ENOENT { + continue + } catch .addWatchFailed(_, let errno) where errno == ENOSPC { + self.report(.addWatchFailed(path: directory.string, errno: errno), for: directory) + break + } catch { + self.report(error, for: directory) + } + } + return wds + } + + private func report(_ error: InotifyError, for directory: FilePath) { + self.continuation.yield(.event(.watchFailed(path: directory, error: error))) + } + private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? { if mask.contains(.create) { return .create } if mask.contains(.movedTo) { return .movedTo } @@ -190,23 +226,36 @@ public actor Inotify { guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.exclusions) else { continue } for entry in entries { let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind - self.continuation.yield(RawInotifyEvent( + self.continuation.yield(.raw(RawInotifyEvent( watchDescriptor: wd, mask: mask, cookie: cookie, name: entry.name, synthesized: true - )) + ))) } } } + /// The buffer holds the library's own events next to the kernel's, so + /// the policy is translated for its element type. + private static func bufferedPolicy( + for policy: AsyncStream.Continuation.BufferingPolicy + ) -> AsyncStream.Continuation.BufferingPolicy { + switch policy { + case .unbounded: .unbounded + case .bufferingOldest(let count): .bufferingOldest(count) + case .bufferingNewest(let count): .bufferingNewest(count) + @unknown default: .unbounded + } + } + private static func createEventReader( forFileDescriptor fd: CInt, - bufferingPolicy: AsyncStream.Continuation.BufferingPolicy - ) -> (any DispatchSourceRead, AsyncStream, AsyncStream.Continuation) { - let (stream, continuation) = AsyncStream.makeStream( - of: RawInotifyEvent.self, + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy + ) -> (any DispatchSourceRead, AsyncStream, AsyncStream.Continuation) { + let (stream, continuation) = AsyncStream.makeStream( + of: BufferedEvent.self, bufferingPolicy: bufferingPolicy ) @@ -217,7 +266,7 @@ public actor Inotify { reader.setEventHandler { for rawEvent in InotifyEventParser.parse(fromFileDescriptor: fd) { - continuation.yield(rawEvent) + continuation.yield(.raw(rawEvent)) } } reader.setCancelHandler { @@ -228,4 +277,11 @@ public actor Inotify { return (reader, stream, continuation) } + + /// What waits in the buffer: a kernel event, transformed when it is + /// consumed, or an event the library produced itself. + enum BufferedEvent: Sendable { + case raw(RawInotifyEvent) + case event(InotifyEvent) + } } diff --git a/Sources/Inotify/InotifyError.swift b/Sources/Inotify/InotifyError.swift index db810c5..49158e9 100644 --- a/Sources/Inotify/InotifyError.swift +++ b/Sources/Inotify/InotifyError.swift @@ -1,9 +1,11 @@ import CInotify -public enum InotifyError: Error, Sendable, CustomStringConvertible { +public enum InotifyError: Error, Sendable, Hashable, CustomStringConvertible { case initFailed(errno: Int32) case addWatchFailed(path: String, errno: Int32) case removeWatchFailed(watchDescriptor: Int32, errno: Int32) + /// The directory could not be listed, so its subdirectories are unknown. + case listDirectoryFailed(path: String, errno: Int32) public var description: String { switch self { @@ -13,6 +15,8 @@ public enum InotifyError: Error, Sendable, CustomStringConvertible { "inotify_add_watch failed for '\(path)': \(readableErrno(code))" case .removeWatchFailed(let wd, let code): "inotify_rm_watch failed for wd \(wd): \(readableErrno(code))" + case .listDirectoryFailed(let path, let code): + "listing '\(path)' failed: \(readableErrno(code))" } } diff --git a/Sources/Inotify/InotifyEvent.swift b/Sources/Inotify/InotifyEvent.swift index da5eec2..09f361a 100644 --- a/Sources/Inotify/InotifyEvent.swift +++ b/Sources/Inotify/InotifyEvent.swift @@ -1,3 +1,5 @@ +import SystemPackage + /// 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 { @@ -6,4 +8,13 @@ public enum InotifyEvent: Sendable, Hashable { /// The kernel's event queue was full, so it dropped events. Consumers /// that must not miss changes should rescan the watched trees. case queueOverflow + /// A directory that appeared in a tree watched with automatic subtree + /// watching could not be watched, so changes below it go unreported. + /// + /// It follows the event of the directory whose appearance made the + /// library extend the watch. A reached watch limit ends the extension, + /// so the directories after the first failed one are not reported + /// separately. A directory that vanished before it could be watched is + /// not reported, since its removal arrives as an event of its own. + case watchFailed(path: FilePath, error: InotifyError) } diff --git a/Sources/TaskCLI/Test Command.swift b/Sources/TaskCLI/Test Command.swift index 24fbdf6..c6d4637 100644 --- a/Sources/TaskCLI/Test Command.swift +++ b/Sources/TaskCLI/Test Command.swift @@ -28,6 +28,9 @@ struct TestCommand: AsyncParsableCommand { "-v", "\(currentDirectory):/code", "-v", "swift-inotify-build-cache:/code/.build", "--security-opt", "systempaths=unconfined", + // Root ignores directory permissions unless these are dropped; a + // test relies on an unreadable directory. + "--cap-drop", "DAC_OVERRIDE", "--cap-drop", "DAC_READ_SEARCH", "--platform", Docker.getLinuxPlatformStringWithHostArchitecture(), "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests" diff --git a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift index 61d0f49..1ae5db4 100644 --- a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift +++ b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import SystemPackage @testable import Inotify @Suite("Inotify Limits", .serialized) @@ -66,6 +67,28 @@ struct InotifyLimitTests { } } + /// The tree that grows is larger than the limit, so the extension fails + /// part way; the watches that exist keep working. + @Test func reportsTheDirectoriesItCannotWatchWhenATreeGrows() async throws { + try await withTempDir { dir in + try await withInotifyWatchLimit(of: 100, for: [.userWatches]) { + let grown = "\(dir)/Grown" + let filepath = "\(dir)/after-failure.txt" + let watcher = try Inotify() + try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: [.create]) + try createSubdirectorytree(at: grown, foldersPerLevel: 3, levels: 4) + let untilFailure = await collectEvents(of: watcher, until: { $0.watchFailure != nil }, timeout: .seconds(5)) + try createFile(at: filepath, contents: "hello") + let afterFailure = await collectEvents(of: watcher, until: { $0.fileSystemEvent?.path.string == filepath }, timeout: .seconds(5)) + + let failure = untilFailure.last?.watchFailure + #expect(failure?.error == .addWatchFailed(path: failure?.path.string ?? "", errno: ENOSPC), "Expected a watch failure with ENOSPC, got: \(untilFailure.suffix(3))") + #expect(failure?.path.starts(with: FilePath(grown)) == true, "Expected the failed directory below '\(grown)', got: \(String(describing: failure))") + #expect(afterFailure.last?.fileSystemEvent?.path.string == filepath, "Expected CREATE for '\(filepath)' after the failure, got: \(afterFailure.suffix(3))") + } + } + } + @Test func reportsQueueOverflowInsteadOfDroppingIt() async throws { try await withTempDir { dir in try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) { diff --git a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift index b85a8ca..17bd052 100644 --- a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift +++ b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift @@ -1,4 +1,5 @@ import Foundation +import SystemPackage import Testing @testable import Inotify @@ -158,4 +159,57 @@ struct RecursiveEventTests { #expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)") } } + + /// Needs a process that directory permissions apply to; the test runner + /// drops root's override capabilities for that. + @Test func reportsANewDirectoryItCannotReadAndWatchesItsSiblings() async throws { + try await withTempDir { dir in + let root = "\(dir)/Root" + let treeSource = "\(dir)/Outside/Grown" + let treeDestination = "\(root)/Grown" + let locked = "\(treeDestination)/Locked" + let filepath = "\(treeDestination)/Open/created.txt" + try FileManager.default.createDirectory(atPath: "\(treeSource)/Locked", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: "\(treeSource)/Open", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: "\(treeSource)/Locked") + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked) } + + let events = try await getInotifyEventsForTrigger( + 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: filepath, contents: "hello") + } + + let failures = events.compactMap(\.watchFailure) + #expect(failures.count == 1, "Expected exactly the locked directory to be reported, got: \(events)") + #expect(failures.first?.path == FilePath(locked)) + #expect(failures.first?.error == .listDirectoryFailed(path: locked, errno: EACCES)) + let sibling = events.compactMap(\.fileSystemEvent).first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == filepath } + #expect(sibling != nil, "Expected CREATE inside the readable sibling, got: \(events)") + } + } + + /// Events are transformed as they are consumed, so a directory that is + /// created and removed before consumption starts is gone when the + /// library tries to watch it. + @Test func doesNotReportADirectoryThatVanishedBeforeItCouldBeWatched() async throws { + try await withTempDir { dir in + let vanished = "\(dir)/Vanished" + let watcher = try Inotify() + try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: [.create]) + try FileManager.default.createDirectory(atPath: vanished, withIntermediateDirectories: false) + try FileManager.default.removeItem(atPath: vanished) + + let events = await collectEvents(of: watcher, for: .milliseconds(500)) + + #expect(events.compactMap(\.watchFailure).isEmpty, "Did not expect a watch failure for a vanished directory, got: \(events)") + let created = events.compactMap(\.fileSystemEvent).first { $0.mask.contains(.create) && $0.path.string == vanished } + #expect(created != nil, "Expected CREATE for '\(vanished)', got: \(events)") + } + } } diff --git a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift index fb15c45..0c55bec 100644 --- a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift +++ b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift @@ -1,4 +1,5 @@ import Inotify +import SystemPackage enum RecursivKind { case nonrecursive @@ -48,13 +49,7 @@ func getInotifyEventsForTrigger( try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: mask) } - let eventTask = Task { - var events: [InotifyEvent] = [] - for await event in await watcher.events { - events.append(event) - } - return events - } + let eventTask = Task { await collectEvents(of: watcher) } try await Task.sleep(for: .milliseconds(100)) try await trigger(dir) @@ -64,9 +59,54 @@ func getInotifyEventsForTrigger( return await eventTask.value } +/// Everything `watcher` delivers until the current task is cancelled. +func collectEvents(of watcher: Inotify) async -> [InotifyEvent] { + var events: [InotifyEvent] = [] + for await event in await watcher.events { + events.append(event) + } + return events +} + +/// Everything `watcher` delivers up to and including the first event that +/// satisfies `predicate`, or until `timeout` passes. +func collectEvents( + of watcher: Inotify, + until predicate: @escaping @Sendable (InotifyEvent) -> Bool, + timeout: Duration +) async -> [InotifyEvent] { + let eventTask = Task { () -> [InotifyEvent] in + var events: [InotifyEvent] = [] + for await event in await watcher.events { + events.append(event) + if predicate(event) { break } + } + return events + } + let timeoutTask = Task { + try? await Task.sleep(for: timeout) + eventTask.cancel() + } + defer { timeoutTask.cancel() } + return await eventTask.value +} + +/// Everything `watcher` delivers within `duration`. +func collectEvents(of watcher: Inotify, for duration: Duration) async -> [InotifyEvent] { + let eventTask = Task { await collectEvents(of: watcher) } + try? await Task.sleep(for: duration) + eventTask.cancel() + return await eventTask.value +} + extension InotifyEvent { var fileSystemEvent: FileSystemEvent? { if case .fileSystem(let event) = self { return event } return nil } + + var watchFailure: (path: FilePath, error: InotifyError)? { + if case .watchFailed(let path, let error) = self { return (path, error) } + return nil + } }