Deliver an event enum with the queue overflow as its own case

The stream's element is now the enum InotifyEvent, and the struct that
describes a change to a watched item is FileSystemEvent. A queue
overflow was an event with descriptor -1 and an empty path that every
consumer had to know about; as a case, the compiler makes them handle
it. The enum is also where failed watches of a growing tree will be
reported, since no call site can catch them.
This commit is contained in:
T. R. Bernstein
2026-09-19 00:10:35 +02:00
parent f01e16e864
commit 3c852a565c
8 changed files with 94 additions and 52 deletions
+8 -3
View File
@@ -41,7 +41,12 @@ try inotify.addWatch(path: "/tmp/watched", mask: [.create, .modify])
// Consume events as they arrive // Consume events as they arrive
for await event in await inotify.events { 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`. 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 ## Removing a Watch
+32
View File
@@ -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
)
}
}
+8 -2
View File
@@ -4,14 +4,19 @@ 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. 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 ```swift
let inotify = try Inotify() let inotify = try Inotify()
try inotify.addWatch(path: "/tmp/inbox", mask: [.create, .modify]) try inotify.addWatch(path: "/tmp/inbox", mask: [.create, .modify])
for await event in await inotify.events { 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`` - ``Inotify/Inotify``
- ``InotifyEvent`` - ``InotifyEvent``
- ``FileSystemEvent``
- ``InotifyEventMask`` - ``InotifyEventMask``
### Articles ### Articles
@@ -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. 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.
+6 -6
View File
@@ -119,22 +119,22 @@ public actor Inotify {
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 InotifyEvent(from: rawEvent, inDirectory: "") return .queueOverflow
} }
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.exclusions.excludes(rawEvent.name) 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.forgetWatchInCaseTheKernelRemovedIt(event)
self.removeWatchesInCaseADirectoryLeftTheTree(event) self.removeWatchesInCaseADirectoryLeftTheTree(event)
await self.addWatchInCaseOfAutomaticSubtreeWatching(event) await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
return event return .fileSystem(event)
} }
/// The kernel reports `IN_IGNORED` once a watch is gone, whether it was /// The kernel reports `IN_IGNORED` once a watch is gone, whether it was
/// removed explicitly or because its item was deleted or unmounted. /// removed explicitly or because its item was deleted or unmounted.
/// Forgetting it keeps a reused descriptor number from mapping to a /// Forgetting it keeps a reused descriptor number from mapping to a
/// stale path. /// stale path.
private func forgetWatchInCaseTheKernelRemovedIt(_ event: InotifyEvent) { private func forgetWatchInCaseTheKernelRemovedIt(_ event: FileSystemEvent) {
guard event.mask.contains(.ignored) else { return } guard event.mask.contains(.ignored) else { return }
self.watches.remove(forId: event.watchDescriptor) 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, /// A directory moved out of a watched tree keeps its kernel watches,
/// which would then report events under the old path. Those watches /// which would then report events under the old path. Those watches
/// are removed instead. /// are removed instead.
private func removeWatchesInCaseADirectoryLeftTheTree(_ event: InotifyEvent) { private func removeWatchesInCaseADirectoryLeftTheTree(_ event: FileSystemEvent) {
guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return } guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return }
for wd in self.watches.descriptors(under: event.path.string) { for wd in self.watches.descriptors(under: event.path.string) {
inotify_rm_watch(self.fd, wd) 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, guard !event.synthesized,
watches.isAutomaticSubtreeWatching(event.watchDescriptor), watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.isDir), event.mask.contains(.isDir),
+8 -36
View File
@@ -1,37 +1,9 @@
import SystemPackage /// What an ``Inotify`` instance delivers: a change to a watched item, or a
/// condition that affects which changes it can deliver.
/// A filesystem event delivered by an ``Inotify`` instance. public enum InotifyEvent: Sendable, Hashable {
/// /// A change to a watched file or directory.
/// When the kernel's event queue overflows, it drops events and reports a case fileSystem(FileSystemEvent)
/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``. /// The kernel's event queue was full, so it dropped events. Consumers
/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and /// that must not miss changes should rescan the watched trees.
/// its ``path`` is empty. Consumers that must not miss changes should case queueOverflow
/// 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
)
}
} }
@@ -50,7 +50,7 @@ struct InotifyLimitTests {
var received = 0 var received = 0
for await event in await watcher.events { for await event in await watcher.events {
received += 1 received += 1
if event.mask.contains(.queueOverflow) { return (event, received) } if case .queueOverflow = event { return (event, received) }
} }
return (nil, received) return (nil, received)
} }
@@ -65,9 +65,7 @@ struct InotifyLimitTests {
overflowTask.cancel() overflowTask.cancel()
let (overflow, received) = await overflowTask.value let (overflow, received) = await overflowTask.value
#expect(overflow != nil, "Expected a queue overflow event after \(index) file creations and \(received) received events") #expect(overflow == .queueOverflow, "Expected a queue overflow event after \(index) file creations and \(received) received events")
#expect(overflow?.watchDescriptor == -1)
#expect(overflow?.path == "")
} }
} }
} }
@@ -6,6 +6,7 @@ enum RecursivKind {
case withAutomaticSubtreeWatching case withAutomaticSubtreeWatching
} }
/// The file system events an instance delivers around `trigger`.
func getEventsForTrigger( func getEventsForTrigger(
in dir: String, in dir: String,
mask: InotifyEventMask, mask: InotifyEventMask,
@@ -13,6 +14,27 @@ func getEventsForTrigger(
exclude: [String] = [], exclude: [String] = [],
excludePatterns: [String] = [], excludePatterns: [String] = [],
trigger: @escaping (String) async throws -> Void, 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] { ) async throws -> [InotifyEvent] {
let watcher = try Inotify() let watcher = try Inotify()
await watcher.exclude(names: exclude) await watcher.exclude(names: exclude)
@@ -41,3 +63,10 @@ func getEventsForTrigger(
eventTask.cancel() eventTask.cancel()
return await eventTask.value return await eventTask.value
} }
extension InotifyEvent {
var fileSystemEvent: FileSystemEvent? {
if case .fileSystem(let event) = self { return event }
return nil
}
}