Report the directories a growing tree could not watch
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s

Extending an automatically watched tree to a new directory swallowed
every error, so a reached watch limit or an unreadable directory left
part of the tree unwatched without any sign. No call of the consumer is
running at that moment, so the failures now arrive in the event stream
as InotifyEvent.watchFailed, after the event that triggered the
extension. The library watches what it can first: a reached limit ends
the attempt, an unreadable directory is skipped with its subtree, and a
directory that vanished in between is not reported.

The buffer now carries the library's own events next to the kernel's,
which keeps them in order and hides the stream's element type. The
test runner drops root's DAC capabilities so that an unreadable
directory can be tested.
This commit is contained in:
T. R. Bernstein
2026-09-19 00:56:31 +02:00
parent 3215d2eb5a
commit c037302c01
11 changed files with 290 additions and 26 deletions
+4
View File
@@ -46,6 +46,8 @@ for await event in await inotify.events {
print("Event at \(change.path): \(change.mask)") print("Event at \(change.path): \(change.mask)")
case .queueOverflow: case .queueOverflow:
print("The kernel dropped events; rescan if you must not miss changes.") 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. 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 ## 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: 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:
+46
View File
@@ -1,3 +1,4 @@
import CInotify
import _NIOFileSystem import _NIOFileSystem
public struct DirectoryResolver { public struct DirectoryResolver {
@@ -19,6 +20,45 @@ public struct DirectoryResolver {
return resolved 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. /// 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)] { static func entries(of directory: FilePath, excluding exclusions: ExclusionList = ExclusionList()) async throws -> [(name: String, isDirectory: Bool)] {
let directoryHandle = try await fileManager.openDirectory(atPath: directory) let directoryHandle = try await fileManager.openDirectory(atPath: directory)
@@ -45,3 +85,9 @@ public struct DirectoryResolver {
try await directoryHandle.close() 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)] = []
}
+3 -1
View File
@@ -4,7 +4,7 @@ Monitor filesystem events on Linux using modern Swift concurrency.
## Overview ## 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 ```swift
let inotify = try Inotify() let inotify = try Inotify()
@@ -16,6 +16,8 @@ for await event in await inotify.events {
print("\(change.mask) at \(change.path)") print("\(change.mask) at \(change.path)")
case .queueOverflow: case .queueOverflow:
print("events were dropped, rescan") print("events were dropped, rescan")
case .watchFailed(let path, let error):
print("changes below \(path) go unreported: \(error)")
} }
} }
``` ```
@@ -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 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 ### 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: 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:
+73 -17
View File
@@ -7,9 +7,9 @@ public actor Inotify {
private var exclusions = ExclusionList() private var exclusions = ExclusionList()
private var watches = InotifyWatchManager() private var watches = InotifyWatchManager()
private nonisolated(unsafe) let eventReader: any DispatchSourceRead private nonisolated(unsafe) let eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent> private nonisolated let eventStream: AsyncStream<BufferedEvent>
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation private nonisolated let continuation: AsyncStream<BufferedEvent>.Continuation
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> { public nonisolated var events: some AsyncSequence<InotifyEvent, Never> {
self.eventStream.compactMap(self.transform(_:)) self.eventStream.compactMap(self.transform(_:))
} }
@@ -22,14 +22,14 @@ public actor Inotify {
/// reading ``events``. The default `.unbounded` keeps every event, so a /// reading ``events``. The default `.unbounded` keeps every event, so a
/// burst of changes is never lost; a bounded policy trades memory for /// burst of changes is never lost; a bounded policy trades memory for
/// dropped events. /// dropped events.
public init(bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy = .unbounded) throws { public init(bufferingPolicy: AsyncStream<InotifyEvent>.Continuation.BufferingPolicy = .unbounded) throws {
self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC)) self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC))
guard self.fd >= 0 else { guard self.fd >= 0 else {
throw InotifyError.initFailed(errno: cinotify_get_errno()) throw InotifyError.initFailed(errno: cinotify_get_errno())
} }
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader( (self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
forFileDescriptor: fd, forFileDescriptor: fd,
bufferingPolicy: bufferingPolicy bufferingPolicy: Self.bufferedPolicy(for: bufferingPolicy)
) )
} }
@@ -75,7 +75,7 @@ public actor Inotify {
} }
@discardableResult @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) let wd = inotify_add_watch(self.fd, path, mask.rawValue)
guard wd >= 0 else { guard wd >= 0 else {
throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno()) throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno())
@@ -108,7 +108,7 @@ public actor Inotify {
return wds return wds
} }
public func removeWatch(_ wd: CInt) throws { public func removeWatch(_ wd: CInt) throws(InotifyError) {
guard inotify_rm_watch(self.fd, wd) == 0 else { guard inotify_rm_watch(self.fd, wd) == 0 else {
throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno()) throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno())
} }
@@ -123,6 +123,13 @@ public actor Inotify {
self.eventReader.cancel() 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? { private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
if rawEvent.mask.contains(.queueOverflow) { if rawEvent.mask.contains(.queueOverflow) {
return .queueOverflow return .queueOverflow
@@ -166,15 +173,44 @@ public actor Inotify {
guard !event.synthesized, guard !event.synthesized,
watches.isAutomaticSubtreeWatching(event.watchDescriptor), watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.isDir), 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 return
} }
guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return } let wds = await self.extendWatches(to: event.path, mask: mask)
guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return } watches.enableAutomaticSubtreeWatching(forIds: wds)
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie) 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? { private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? {
if mask.contains(.create) { return .create } if mask.contains(.create) { return .create }
if mask.contains(.movedTo) { return .movedTo } 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 } guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.exclusions) else { continue }
for entry in entries { for entry in entries {
let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind
self.continuation.yield(RawInotifyEvent( self.continuation.yield(.raw(RawInotifyEvent(
watchDescriptor: wd, watchDescriptor: wd,
mask: mask, mask: mask,
cookie: cookie, cookie: cookie,
name: entry.name, name: entry.name,
synthesized: true 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<InotifyEvent>.Continuation.BufferingPolicy
) -> AsyncStream<BufferedEvent>.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( private static func createEventReader(
forFileDescriptor fd: CInt, forFileDescriptor fd: CInt,
bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy bufferingPolicy: AsyncStream<BufferedEvent>.Continuation.BufferingPolicy
) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>, AsyncStream<RawInotifyEvent>.Continuation) { ) -> (any DispatchSourceRead, AsyncStream<BufferedEvent>, AsyncStream<BufferedEvent>.Continuation) {
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream( let (stream, continuation) = AsyncStream<BufferedEvent>.makeStream(
of: RawInotifyEvent.self, of: BufferedEvent.self,
bufferingPolicy: bufferingPolicy bufferingPolicy: bufferingPolicy
) )
@@ -217,7 +266,7 @@ public actor Inotify {
reader.setEventHandler { reader.setEventHandler {
for rawEvent in InotifyEventParser.parse(fromFileDescriptor: fd) { for rawEvent in InotifyEventParser.parse(fromFileDescriptor: fd) {
continuation.yield(rawEvent) continuation.yield(.raw(rawEvent))
} }
} }
reader.setCancelHandler { reader.setCancelHandler {
@@ -228,4 +277,11 @@ public actor Inotify {
return (reader, stream, continuation) 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)
}
} }
+5 -1
View File
@@ -1,9 +1,11 @@
import CInotify import CInotify
public enum InotifyError: Error, Sendable, CustomStringConvertible { public enum InotifyError: Error, Sendable, Hashable, CustomStringConvertible {
case initFailed(errno: Int32) case initFailed(errno: Int32)
case addWatchFailed(path: String, errno: Int32) case addWatchFailed(path: String, errno: Int32)
case removeWatchFailed(watchDescriptor: Int32, 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 { public var description: String {
switch self { switch self {
@@ -13,6 +15,8 @@ public enum InotifyError: Error, Sendable, CustomStringConvertible {
"inotify_add_watch failed for '\(path)': \(readableErrno(code))" "inotify_add_watch failed for '\(path)': \(readableErrno(code))"
case .removeWatchFailed(let wd, let code): case .removeWatchFailed(let wd, let code):
"inotify_rm_watch failed for wd \(wd): \(readableErrno(code))" "inotify_rm_watch failed for wd \(wd): \(readableErrno(code))"
case .listDirectoryFailed(let path, let code):
"listing '\(path)' failed: \(readableErrno(code))"
} }
} }
+11
View File
@@ -1,3 +1,5 @@
import SystemPackage
/// What an ``Inotify`` instance delivers: a change to a watched item, or a /// What an ``Inotify`` instance delivers: a change to a watched item, or a
/// condition that affects which changes it can deliver. /// condition that affects which changes it can deliver.
public enum InotifyEvent: Sendable, Hashable { 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 /// The kernel's event queue was full, so it dropped events. Consumers
/// that must not miss changes should rescan the watched trees. /// that must not miss changes should rescan the watched trees.
case queueOverflow 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)
} }
+3
View File
@@ -28,6 +28,9 @@ struct TestCommand: AsyncParsableCommand {
"-v", "\(currentDirectory):/code", "-v", "\(currentDirectory):/code",
"-v", "swift-inotify-build-cache:/code/.build", "-v", "swift-inotify-build-cache:/code/.build",
"--security-opt", "systempaths=unconfined", "--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(), "--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"-w", "/code", "swift:latest", "-w", "/code", "swift:latest",
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests" "/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
@@ -1,5 +1,6 @@
import Testing import Testing
import Foundation import Foundation
import SystemPackage
@testable import Inotify @testable import Inotify
@Suite("Inotify Limits", .serialized) @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 { @Test func reportsQueueOverflowInsteadOfDroppingIt() async throws {
try await withTempDir { dir in try await withTempDir { dir in
try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) { try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import SystemPackage
import Testing import Testing
@testable import Inotify @testable import Inotify
@@ -158,4 +159,57 @@ struct RecursiveEventTests {
#expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)") #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)")
}
}
} }
@@ -1,4 +1,5 @@
import Inotify import Inotify
import SystemPackage
enum RecursivKind { enum RecursivKind {
case nonrecursive case nonrecursive
@@ -48,13 +49,7 @@ func getInotifyEventsForTrigger(
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: mask) try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: mask)
} }
let eventTask = Task { let eventTask = Task { await collectEvents(of: watcher) }
var events: [InotifyEvent] = []
for await event in await watcher.events {
events.append(event)
}
return events
}
try await Task.sleep(for: .milliseconds(100)) try await Task.sleep(for: .milliseconds(100))
try await trigger(dir) try await trigger(dir)
@@ -64,9 +59,54 @@ func getInotifyEventsForTrigger(
return await eventTask.value 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 { extension InotifyEvent {
var fileSystemEvent: FileSystemEvent? { var fileSystemEvent: FileSystemEvent? {
if case .fileSystem(let event) = self { return event } if case .fileSystem(let event) = self { return event }
return nil return nil
} }
var watchFailure: (path: FilePath, error: InotifyError)? {
if case .watchFailed(let path, let error) = self { return (path, error) }
return nil
}
} }