21 Commits
Author SHA1 Message Date
T. R. Bernstein c037302c01 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.
2026-09-19 00:56:31 +02:00
T. R. Bernstein 3215d2eb5a Describe the errno an error stores, not the current one
The description looked up the message of whatever errno was set when
the description was built, so an error printed later read
"Success (errno 28)".
2026-09-19 00:45:22 +02:00
T. R. Bernstein 21f096aede Remove the watches a failed tree call had already added
A recursive watch that hit the watch limit or an unreadable directory
threw after adding watches for part of the tree, which stayed in the
instance and kept counting against the user's limit. The call now
leaves the instance as it found it.

The test needs a second instance for the check, because a repeated
watch on the same instance only updates the existing one.
2026-09-19 00:43:37 +02:00
T. R. Bernstein 3c852a565c 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.
2026-09-19 00:10:35 +02:00
T. R. Bernstein f01e16e864 Pin the docs workflow to Swift 6.3
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s
Without a version the setup action installs the latest toolchain.
Swift 6.4.0 shipped on 2026-09-15 and the swiftly build cached on the
runner cannot install it, so the docs job started failing.
2026-09-16 21:01:26 +02:00
T. R. Bernstein 8af25be549 Exclude items by shell pattern as well as by name
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s
Patterns such as `.*` or `@*` are matched against an item's own name
with `fnmatch`, in the same places as excluded names: resolving a
tree, extending a watch to a directory that appears later, and
delivering events. Dependents that prune large trees can now skip
whole families of directories without listing each name.
2026-09-16 11:21:45 +02:00
T. R. Bernstein 442053eae2 Move the event mask into a platform-neutral product
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s
`InotifyEventMask` took its bits from the C header, so nothing that
imported it could build outside Linux. The new `InotifyMask` product
spells out the kernel constants instead; a Linux test compares each
of them with the header. `Inotify` re-exports the module, so
existing code is unaffected.
2026-09-14 00:13:19 +02:00
T. R. Bernstein c79691cb6f Stop descending into excluded directories
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s
The resolver skipped an excluded directory in its result but still
walked its subtree, so watches were installed below names such as
`.git` or `node_modules`. Exclusion now prunes the walk.
2026-09-13 23:15:21 +02:00
T. R. Bernstein 134034f3ea Watch directories moved into the tree and report their content
Automatic subtree watching only reacted to `CREATE`, so a directory
moved in from elsewhere stayed unwatched. It is now handled like a
created one. Items that already exist in such a directory never
produce kernel events; they are reported with the same event kind
and `synthesized` set to `true`, so consumers can treat them as
newly appeared.
2026-09-13 23:13:32 +02:00
T. R. Bernstein e6ed232087 Drop watches of directories that leave the tree
A directory moved out of a watched tree kept its kernel watches, so
later changes inside it were reported under the old path. Its
watches and those of its subdirectories are now removed on
`MOVED_FROM`. Watches the kernel reports as `IGNORED` are forgotten
as well, so a reused descriptor number cannot map to a stale path.
2026-09-13 23:10:30 +02:00
T. R. Bernstein 6375a23328 Report queue overflows instead of dropping them
`IN_Q_OVERFLOW` arrives with watch descriptor -1, so the path lookup
failed and the event was silently discarded. It is now delivered
with an empty path so consumers can rescan after the kernel dropped
events.
2026-09-13 23:06:28 +02:00
T. R. Bernstein 68f49e254c Restore inotify limits even when a test body throws
The limits were only written back after a successful body, so a
failing limit test left the shared kernel at the lowered values and
every later run failed with ENOSPC. The helper now restores in a
`defer` and can lower a chosen subset of the limits.
2026-09-13 23:06:28 +02:00
T. R. Bernstein d2abc3355e Keep every event by default
Events were buffered with `bufferingNewest(512)`, so a burst of
changes silently lost all but the newest 512 events whenever the
consumer lagged. The stream now buffers without limit, and
`init(bufferingPolicy:)` lets callers choose a bounded policy.
2026-09-13 23:00:13 +02:00
T. R. Bernstein dcc08eb928 Cancel the event reader before closing the descriptor
`deinit` closed the inotify descriptor while its dispatch source was
still active. The kernel drops the epoll registration on close, but
libdispatch keeps its own; an instance created afterwards that reuses
the descriptor number could inherit that stale state and never
receive events. Roughly one test run in three lost a single event
this way.

The reader is now cancelled in `deinit` and the descriptor closed in
its cancel handler, as libdispatch requires.
2026-09-13 22:59:15 +02:00
T. R. Bernstein 39f3428bff Propagate first-pass failures in the test task
The two `swift test` passes were joined with `;`, so a failing main
pass was masked by a green limit-test pass.
2026-09-13 22:28:26 +02:00
T. R. Bernstein 8cedee6139 Support swift-subprocess 1.x
The exclusive `from: "0.3.0"` range blocked dependents that already
use swift-subprocess 1.0. Version 1.0 renamed the stdio outputs used
by the task CLI to `currentStandardOutput` and `currentStandardError`.
2026-09-13 22:28:26 +02:00
T. R. Bernstein 10943f9ce3 Make events property of Inotify nonisolated
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled
Properties of actors are implicitly isolated. To be able to read the
events stream from any concurrent context, we need to declare it
nonisolated. And as AsyncStream conforms to Sendable, it is safe to make
both events and the private eventStream nonisolated.
2026-03-23 20:15:57 +01:00
T. R. Bernstein 6927464d47 Use Subprocess instead of Shwift
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled
Drop Shwift: it is incompatible with musl (used by the Swift static
linking SDK), and its API is not meaningfully more concise than
Subprocess upon closer inspection.
2026-03-23 19:50:58 +01:00
T. R. Bernstein 31ed16c828 Cache build directory of linux containers
SwiftPM uses caches heavily to reduce compilation and download time.
Before this change, we through these caches away with each container.
2026-03-22 17:51:24 +01:00
T. R. Bernstein ac1c86c431 Run linux container with same architechture as host
As the development team uses both Intel and Apple Silicon Macs,
we have to get the host CPU architecture at compilation time instead of
harcoding it.
If the container has a different architecture, the guest has to be
emulated.
2026-03-22 17:51:24 +01:00
T. R. Bernstein 4b28c293cb Use Shwift library instead of Subprocess
Shwift has a concise API, which makes writing shell code nice and easy.
This is an opinionated decision.
2026-03-22 17:51:05 +01:00
34 changed files with 1045 additions and 197 deletions
+1
View File
@@ -26,6 +26,7 @@ jobs:
- name: Set up Swift
uses: swift-actions/setup-swift@v3
with:
swift-version: "6.3"
skip-verify-signature: true
- name: Generate Docs
run: |
+12 -12
View File
@@ -1,5 +1,5 @@
{
"originHash" : "0cb2e87817f52021ac25ffee6b27396f6d94e9fd604ca83db7f20a10e65fe6cf",
"originHash" : "d30dadbb08ce17a04cba957d25e81d1d76b8dc0a7bdc84a591c7af3b8eb74b85",
"pins" : [
{
"identity" : "noora",
@@ -28,22 +28,13 @@
"version" : "4.2.1"
}
},
{
"identity" : "shwift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/GeorgeLyon/Shwift",
"state" : {
"revision" : "d7be04898d094ddce6140cc6a2e9a83fc994b66d",
"version" : "3.1.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
"version" : "1.7.1"
}
},
{
@@ -82,6 +73,15 @@
"version" : "2.95.0"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "b3937ab85dd32f6e9435914599c1519074769c1a",
"version" : "1.0.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
+11 -3
View File
@@ -8,21 +8,28 @@ let package = Package(
.library(
name: "Inotify",
targets: ["Inotify"]
)
),
.library(
name: "InotifyMask",
targets: ["InotifyMask"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
.package(url: "https://github.com/GeorgeLyon/Shwift", from: "3.1.1"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", "0.3.0"..<"2.0.0"),
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
],
targets: [
.systemLibrary(name: "CInotify"),
.target(name: "InotifyMask"),
.target(
name: "Inotify",
dependencies: [
"CInotify",
"InotifyMask",
.product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "SystemPackage", package: "swift-system")
@@ -38,9 +45,10 @@ let package = Package(
.executableTarget(
name: "InotifyTaskCLI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "Script", package: "Shwift"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Noora", package: "Noora")
],
path: "Sources/TaskCLI"
+25 -5
View File
@@ -41,7 +41,14 @@ 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.")
case .watchFailed(let path, let error):
print("Changes below \(path) go unreported: \(error)")
}
}
```
@@ -64,7 +71,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,9 +82,15 @@ 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.
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. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and 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:
```swift
let inotify = try Inotify()
@@ -85,18 +98,23 @@ let inotify = try Inotify()
// Ignore version-control and build directories
await inotify.exclude(names: ".git", "node_modules", ".build")
// Ignore every hidden item and every metadata directory of a NAS
await inotify.exclude(patterns: ".*", "@eaDir")
try await inotify.addWatchWithAutomaticSubtreeWatching(
forDirectory: "/home/user/project",
mask: [.create, .modify, .delete]
)
```
Use `isExcluded(_:)` to check whether a name is currently on the exclusion list.
A pattern is matched against an item's own name, not its path, the way the shell matches file names: `*` and `?` stand for any characters and `[…]` for a set of characters. Use `isExcluded(_:)` to check whether a name is currently excluded.
## Event Masks
`InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely.
The mask lives in the separate `InotifyMask` product, which has no Linux dependency. Depend on it alone where code only stores or compares masks and must build or be tested on other platforms; `Inotify` re-exports it.
| Mask | Description |
|------|-------------|
| `.access` | File was read |
@@ -116,7 +134,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 `InotifyEvent.queueOverflow` is delivered instead of a file system event; rescan the watched directories if you must not miss changes.
## Removing a Watch
+3 -6
View File
@@ -5,6 +5,7 @@
#include <sys/inotify.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
static inline int cinotify_deinit(int fd) {
return close(fd);
@@ -14,12 +15,8 @@ static inline int cinotify_get_errno(void) {
return errno;
}
static inline char* get_error_message() {
int error_number = errno;
errno = 0;
char* error_message = strerror(error_number);
if (errno > 0) return NULL;
return error_message;
static inline char* cinotify_error_message(int error_number) {
return strerror(error_number);
}
#endif
+67 -11
View File
@@ -1,37 +1,93 @@
import CInotify
import _NIOFileSystem
public struct DirectoryResolver {
static let fileManager = FileSystem.shared
public static func resolve(_ paths: String..., excluding itemNames: Set<String> = []) async throws -> [FilePath] {
try await Self.resolve(paths, excluding: itemNames)
try await Self.resolve(paths, excluding: ExclusionList(names: itemNames))
}
static func resolve(_ paths: [String], excluding itemNames: Set<String> = []) async throws -> [FilePath] {
static func resolve(_ paths: [String], excluding exclusions: ExclusionList = ExclusionList()) async throws -> [FilePath] {
var resolved: [FilePath] = []
for path in paths {
let path = FilePath(path)
resolved.append(path)
try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in
guard let basename = subdirectoryPath.lastComponent?.description else { return }
guard !itemNames.contains(basename) else { return }
resolved.append(subdirectoryPath)
}
try await withSubdirectories(at: path, excluding: exclusions) { resolved.append($0) }
}
return resolved
}
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
/// 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)
var entries: [(name: String, isDirectory: Bool)] = []
for try await childContent in directoryHandle.listContents() {
guard let name = childContent.path.lastComponent?.string else { continue }
guard !exclusions.excludes(name) else { continue }
entries.append((name: name, isDirectory: childContent.type == .directory))
}
try await directoryHandle.close()
return entries
}
/// Calls `body` for every subdirectory below `path`, depth first. Excluded
/// directories are neither reported nor descended into.
private static func withSubdirectories(at path: FilePath, excluding exclusions: ExclusionList, body: (FilePath) async throws -> Void) async throws {
let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() {
guard childContent.type == .directory else { continue }
guard let name = childContent.path.lastComponent?.string, !exclusions.excludes(name) else { continue }
try await body(childContent.path)
if recursive {
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
}
try await withSubdirectories(at: childContent.path, excluding: exclusions, body: body)
}
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)] = []
}
+33
View File
@@ -0,0 +1,33 @@
#if canImport(Musl)
import Musl
#else
import Glibc
#endif
/// The item names an ``Inotify`` instance skips: exact names and shell
/// patterns, both matched against an item's own name.
struct ExclusionList: Sendable {
private var names: Set<String> = []
private var patterns: [String] = []
init(names: Set<String> = [], patterns: [String] = []) {
self.names = names
self.patterns = patterns
}
mutating func add(name: String) {
self.names.insert(name)
}
mutating func add(pattern: String) {
guard !self.patterns.contains(pattern) else { return }
self.patterns.append(pattern)
}
/// Patterns are matched as the shell matches file names: `*` and `?`
/// stand for any characters, `[]` for a set, and a leading dot needs
/// no special treatment.
func excludes(_ name: String) -> Bool {
self.names.contains(name) || self.patterns.contains { fnmatch($0, name, 0) == 0 }
}
}
+3
View File
@@ -0,0 +1,3 @@
// The mask lives in its own module so that it is usable off Linux; users
// of `Inotify` keep seeing it as before.
@_exported import InotifyMask
+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
)
}
}
+11 -3
View File
@@ -4,14 +4,21 @@ 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, after a kernel queue overflow or when a new directory of a watched tree could not be watched.
```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")
case .watchFailed(let path, let error):
print("changes below \(path) go unreported: \(error)")
}
}
```
@@ -20,7 +27,7 @@ Beyond single-directory watches, the library provides two higher-level methods f
- ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` installs watches on every existing subdirectory at setup time.
- ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` does the same **and** automatically watches subdirectories that are created after setup.
You can also exclude certain file or directory names so that they are skipped during directory resolution and silently dropped from the event stream. See ``Inotify/Inotify/exclude(names:)`` and <doc:WatchingDirectoryTrees> for details.
You can also exclude certain file or directory names, exactly or by shell pattern, so that they are skipped during directory resolution and silently dropped from the event stream. See ``Inotify/Inotify/exclude(names:)``, ``Inotify/Inotify/exclude(patterns:)`` and <doc:WatchingDirectoryTrees> for details.
All public types conform to `Sendable`, so they can be safely passed across concurrency boundaries.
@@ -30,6 +37,7 @@ All public types conform to `Sendable`, so they can be safely passed across conc
- ``Inotify/Inotify``
- ``InotifyEvent``
- ``FileSystemEvent``
- ``InotifyEventMask``
### Articles
@@ -18,7 +18,7 @@ let descriptors = try await inotify.addRecursiveWatch(
)
```
The returned array contains one watch descriptor per directory. Subdirectories created **after** this call are not covered.
The returned array contains one watch descriptor per directory. Subdirectories created **after** this call are not covered. When one of the directories cannot be watched, for instance because the user's watch limit is reached, the call throws and removes the watches it had added, so the instance is left as it was.
### Automatic Subtree Watching
@@ -31,15 +31,39 @@ 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 ``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 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:)`` **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:
```swift
let inotify = try Inotify()
await inotify.exclude(names: ".git", "node_modules", ".build")
await inotify.exclude(patterns: ".*", "*.tmp")
try await inotify.addWatchWithAutomaticSubtreeWatching(
forDirectory: "/home/user/project",
@@ -47,7 +71,7 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
)
```
Excluded names are matched against the last path component of each directory during resolution and are also filtered from the event stream, so you never receive events for items whose name is on the exclusion list.
Excluded names and patterns are matched against the last path component of each directory during resolution, against a directory that appears later before a watch is extended to it, and against every event, so you never receive events for excluded items. A pattern is matched the way the shell matches file names: `*` and `?` stand for any characters and `[…]` for a set of characters; a leading dot needs no special treatment.
### Choosing the Right Method
+196 -30
View File
@@ -1,30 +1,46 @@
import Dispatch
import CInotify
import SystemPackage
public actor Inotify {
private let fd: CInt
private var excludedItemNames: Set<String> = []
private var exclusions = ExclusionList()
private var watches = InotifyWatchManager()
private var eventReader: any DispatchSourceRead
private var eventStream: AsyncStream<RawInotifyEvent>
public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
private nonisolated(unsafe) let eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<BufferedEvent>
private nonisolated let continuation: AsyncStream<BufferedEvent>.Continuation
public nonisolated var events: some AsyncSequence<InotifyEvent, Never> {
self.eventStream.compactMap(self.transform(_:))
}
public init() throws {
/// Creates an inotify instance.
///
/// Events are read from the kernel as soon as they arrive and buffered
/// until they are consumed from ``events``.
///
/// - Parameter bufferingPolicy: How events are kept while no consumer is
/// 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<InotifyEvent>.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.createEventReader(forFileDescriptor: fd)
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
forFileDescriptor: fd,
bufferingPolicy: Self.bufferedPolicy(for: bufferingPolicy)
)
}
/// Whether an item with this name is skipped, by an excluded name or
/// an excluded pattern.
public func isExcluded(_ name: String) -> Bool {
self.excludedItemNames.contains(name)
self.exclusions.excludes(name)
}
public func exclude(name: String) {
self.excludedItemNames.insert(name)
self.exclusions.add(name: name)
}
public func exclude(names: String...) {
@@ -33,12 +49,33 @@ public actor Inotify {
public func exclude(names: [String]) {
for name in names {
self.excludedItemNames.insert(name)
self.exclusions.add(name: name)
}
}
/// Excludes every item whose name matches a shell pattern such as
/// `*.tmp` or `@*`, with the same effect as an excluded name.
///
/// The pattern is matched against the item's own name, not its path,
/// as the shell matches file names: `*` and `?` stand for any
/// characters and `[]` for a set of characters. A leading dot needs
/// no special treatment, so `.*` excludes hidden items.
public func exclude(pattern: String) {
self.exclusions.add(pattern: pattern)
}
public func exclude(patterns: String...) {
self.exclude(patterns: patterns)
}
public func exclude(patterns: [String]) {
for pattern in patterns {
self.exclusions.add(pattern: pattern)
}
}
@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())
@@ -47,13 +84,19 @@ public actor Inotify {
return wd
}
/// Watches `path` and every directory below it, or throws and leaves no
/// watch behind when one of them cannot be watched.
@discardableResult
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
let directoryPaths = try await DirectoryResolver.resolve(path, excluding: self.excludedItemNames)
let directoryPaths = try await DirectoryResolver.resolve([path], excluding: self.exclusions)
var result: [CInt] = []
do {
for path in directoryPaths {
let wd = try self.addWatch(path: path.string, mask: mask)
result.append(wd)
result.append(try self.addWatch(path: path.string, mask: mask))
}
} catch {
self.dropWatches(result)
throw error
}
return result
}
@@ -65,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())
}
@@ -73,32 +116,147 @@ public actor Inotify {
}
deinit {
cinotify_deinit(self.fd)
// The file descriptor is closed by the reader's cancel handler once
// libdispatch has unregistered it. Closing it here would leave a
// registration behind that a later instance reusing the descriptor
// number could inherit, silently losing its events.
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
}
guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
guard !self.exclusions.excludes(rawEvent.name) else { return nil }
let event = FileSystemEvent(from: rawEvent, inDirectory: path)
self.forgetWatchInCaseTheKernelRemovedIt(event)
self.removeWatchesInCaseADirectoryLeftTheTree(event)
await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
return InotifyEvent.init(from: rawEvent, inDirectory: path)
return .fileSystem(event)
}
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.create),
event.mask.contains(.isDir) else {
/// 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: FileSystemEvent) {
guard event.mask.contains(.ignored) else { return }
self.watches.remove(forId: event.watchDescriptor)
}
/// 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: FileSystemEvent) {
guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return }
self.dropWatches(self.watches.descriptors(under: event.path.string))
}
/// Removes watches whose failure does not matter, because their item is
/// gone or the watches are given up anyway.
private func dropWatches(_ wds: [CInt]) {
for wd in wds {
inotify_rm_watch(self.fd, wd)
self.watches.remove(forId: wd)
}
}
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: FileSystemEvent) async {
guard !event.synthesized,
watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.isDir),
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 }
let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask)
let wds = await self.extendWatches(to: event.path, mask: mask)
watches.enableAutomaticSubtreeWatching(forIds: wds)
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie)
}
private static func createEventReader(forFileDescriptor fd: CInt) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
of: RawInotifyEvent.self,
bufferingPolicy: .bufferingNewest(512)
/// 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 }
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.exclusions) else { continue }
for entry in entries {
let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind
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<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(
forFileDescriptor fd: CInt,
bufferingPolicy: AsyncStream<BufferedEvent>.Continuation.BufferingPolicy
) -> (any DispatchSourceRead, AsyncStream<BufferedEvent>, AsyncStream<BufferedEvent>.Continuation) {
let (stream, continuation) = AsyncStream<BufferedEvent>.makeStream(
of: BufferedEvent.self,
bufferingPolicy: bufferingPolicy
)
let reader = DispatchSource.makeReadSource(
@@ -108,14 +266,22 @@ public actor Inotify {
reader.setEventHandler {
for rawEvent in InotifyEventParser.parse(fromFileDescriptor: fd) {
continuation.yield(rawEvent)
continuation.yield(.raw(rawEvent))
}
}
reader.setCancelHandler {
cinotify_deinit(fd)
continuation.finish()
}
reader.activate()
return (reader, stream)
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)
}
}
+7 -5
View File
@@ -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,13 +15,13 @@ 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))"
}
}
private func readableErrno(_ code: Int32) -> String {
if let cStr = get_error_message() {
return String(cString: cStr) + " (errno \(code))"
}
return "errno \(code)"
guard let message = cinotify_error_message(code) else { return "errno \(code)" }
return String(cString: message) + " (errno \(code))"
}
}
+17 -23
View File
@@ -1,26 +1,20 @@
import SystemPackage
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let watchDescriptor: Int32
public let mask: InotifyEventMask
public let cookie: UInt32
public let path: FilePath
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)
)
}
/// 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
/// 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)
}
-47
View File
@@ -1,47 +0,0 @@
import CInotify
public struct InotifyEventMask: OptionSet, Sendable, Hashable {
public let rawValue: CUnsignedInt
public init(rawValue: UInt32) {
self.rawValue = rawValue
}
// MARK: - Watchable Events
public static let access = InotifyEventMask(rawValue: CUnsignedInt(IN_ACCESS))
public static let attrib = InotifyEventMask(rawValue: CUnsignedInt(IN_ATTRIB))
public static let closeWrite = InotifyEventMask(rawValue: CUnsignedInt(IN_CLOSE_WRITE))
public static let closeNoWrite = InotifyEventMask(rawValue: CUnsignedInt(IN_CLOSE_NOWRITE))
public static let create = InotifyEventMask(rawValue: CUnsignedInt(IN_CREATE))
public static let delete = InotifyEventMask(rawValue: CUnsignedInt(IN_DELETE))
public static let deleteSelf = InotifyEventMask(rawValue: CUnsignedInt(IN_DELETE_SELF))
public static let modify = InotifyEventMask(rawValue: CUnsignedInt(IN_MODIFY))
public static let moveSelf = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVE_SELF))
public static let movedFrom = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVED_FROM))
public static let movedTo = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVED_TO))
public static let open = InotifyEventMask(rawValue: CUnsignedInt(IN_OPEN))
// MARK: - Combinations
public static let move: InotifyEventMask = [.movedFrom, .movedTo]
public static let close: InotifyEventMask = [.closeWrite, .closeNoWrite]
public static let allEvents: InotifyEventMask = [
.access, .attrib, .closeWrite, .closeNoWrite,
.create, .delete, .deleteSelf, .modify,
.moveSelf, .movedFrom, .movedTo, .open
]
// MARK: - Watch Flags
public static let dontFollow = InotifyEventMask(rawValue: CUnsignedInt(IN_DONT_FOLLOW))
public static let onlyDir = InotifyEventMask(rawValue: CUnsignedInt(IN_ONLYDIR))
public static let oneShot = InotifyEventMask(rawValue: CUnsignedInt(IN_ONESHOT))
// MARK: - Kernel-Only Flags
public static let isDir = InotifyEventMask(rawValue: CUnsignedInt(IN_ISDIR))
public static let ignored = InotifyEventMask(rawValue: CUnsignedInt(IN_IGNORED))
public static let queueOverflow = InotifyEventMask(rawValue: CUnsignedInt(IN_Q_OVERFLOW))
public static let unmount = InotifyEventMask(rawValue: CUnsignedInt(IN_UNMOUNT))
}
+2 -1
View File
@@ -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)
@@ -36,6 +36,14 @@ struct InotifyWatchManager {
return self.watchPaths[watchDescriptor]
}
/// The descriptors of the watch on `path` itself and of every watch below it.
func descriptors(under path: String) -> [CInt] {
let prefix = path.hasSuffix("/") ? path : path + "/"
return self.watchPaths
.filter { $0.value == path || $0.value.hasPrefix(prefix) }
.map(\.key)
}
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
return self.watchMasks[watchDescriptor]
}
+3
View File
@@ -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)\""]
@@ -0,0 +1,51 @@
/// The events and flags of an inotify watch or event, as bits.
///
/// The values are the constants of the Linux `<sys/inotify.h>` header,
/// which are part of the kernel's stable interface. Spelling them out here
/// keeps this module free of the C header, so it builds on every platform
/// and lets code that only stores or compares masks be tested off Linux.
public struct InotifyEventMask: OptionSet, Sendable, Hashable {
public let rawValue: UInt32
public init(rawValue: UInt32) {
self.rawValue = rawValue
}
// MARK: - Watchable Events
public static let access = InotifyEventMask(rawValue: 0x0000_0001)
public static let modify = InotifyEventMask(rawValue: 0x0000_0002)
public static let attrib = InotifyEventMask(rawValue: 0x0000_0004)
public static let closeWrite = InotifyEventMask(rawValue: 0x0000_0008)
public static let closeNoWrite = InotifyEventMask(rawValue: 0x0000_0010)
public static let open = InotifyEventMask(rawValue: 0x0000_0020)
public static let movedFrom = InotifyEventMask(rawValue: 0x0000_0040)
public static let movedTo = InotifyEventMask(rawValue: 0x0000_0080)
public static let create = InotifyEventMask(rawValue: 0x0000_0100)
public static let delete = InotifyEventMask(rawValue: 0x0000_0200)
public static let deleteSelf = InotifyEventMask(rawValue: 0x0000_0400)
public static let moveSelf = InotifyEventMask(rawValue: 0x0000_0800)
// MARK: - Combinations
public static let move: InotifyEventMask = [.movedFrom, .movedTo]
public static let close: InotifyEventMask = [.closeWrite, .closeNoWrite]
public static let allEvents: InotifyEventMask = [
.access, .attrib, .closeWrite, .closeNoWrite,
.create, .delete, .deleteSelf, .modify,
.moveSelf, .movedFrom, .movedTo, .open,
]
// MARK: - Watch Flags
public static let onlyDir = InotifyEventMask(rawValue: 0x0100_0000)
public static let dontFollow = InotifyEventMask(rawValue: 0x0200_0000)
public static let oneShot = InotifyEventMask(rawValue: 0x8000_0000)
// MARK: - Kernel-Only Flags
public static let unmount = InotifyEventMask(rawValue: 0x0000_2000)
public static let queueOverflow = InotifyEventMask(rawValue: 0x0000_4000)
public static let ignored = InotifyEventMask(rawValue: 0x0000_8000)
public static let isDir = InotifyEventMask(rawValue: 0x4000_0000)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Script
import ArgumentParser
@main
struct Command: AsyncParsableCommand {
+9
View File
@@ -0,0 +1,9 @@
struct Docker {
static func getLinuxPlatformStringWithHostArchitecture() -> String {
#if arch(x86_64)
return "linux/amd64"
#else
return "linux/arm64"
#endif
}
}
@@ -1,9 +1,10 @@
import ArgumentParser
import Foundation
import Logging
import Script
import Noora
import Subprocess
struct GenerateDocumentationCommand: Script {
struct GenerateDocumentationCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "generate-documentation",
abstract: "Generate DocC documentation of all targets inside a Linux container.",
@@ -23,7 +24,6 @@ struct GenerateDocumentationCommand: Script {
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
let docker = try await executable(named: "docker")
let targets = try await Self.targets(for: projectDirectory)
@@ -42,16 +42,21 @@ struct GenerateDocumentationCommand: Script {
let script = Self.makeRunScript(for: targets)
logger.debug("Container script", metadata: ["script": "\(script)"])
do {
try await docker(
let dockerRunResult = try await Subprocess.run(
.name("docker"),
arguments: [
"run", "--rm",
"-v", "\(tempDirectory.path):/code",
"--platform", "linux/arm64",
"-v", "swift-inotify-build-cache:/code/.build",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"-w", "/code",
"swift:latest",
"/bin/bash", "-c", script,
"/bin/bash", "-c", script
],
output: .currentStandardOutput,
error: .currentStandardError
)
} catch {
if !dockerRunResult.terminationStatus.isSuccess {
noora.error("Documentation generation failed.")
return
}
@@ -103,10 +108,12 @@ struct GenerateDocumentationCommand: Script {
}
private static func packageTargets() async throws -> [(name: String, path: String)] {
let swift = try await executable(named: "swift")
let packageDescriptionOutput = try await outputOf {
try await swift("package", "describe", "--type", "json")
}
let packageDescriptionResult = try await Subprocess.run(
.name("swift"),
arguments: ["package", "describe", "--type", "json"],
output: .data(limit: 10_000),
error: .currentStandardError
)
struct PackageDescription: Codable {
let targets: [Target]
@@ -116,8 +123,11 @@ struct GenerateDocumentationCommand: Script {
let path: String
}
let data = Data(packageDescriptionOutput.utf8)
let package = try JSONDecoder().decode(PackageDescription.self, from: data)
if !packageDescriptionResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.unableToReadPackageDescription
}
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
return package.targets.map { ($0.name, $0.path) }
}
@@ -162,13 +172,16 @@ struct GenerateDocumentationCommand: Script {
// MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let swift = try await executable(named: "swift")
do {
try await swift(
let swiftRunResult = try await Subprocess.run(
.name("swift"),
arguments: [
"package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
],
output: .currentStandardOutput,
error: .currentStandardError
)
} catch {
if !swiftRunResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.dependencyInjectionFailed
}
@@ -178,11 +191,14 @@ struct GenerateDocumentationCommand: Script {
enum GenerateDocumentationError: Error, CustomStringConvertible {
case dependencyInjectionFailed
case unableToReadPackageDescription
var description: String {
switch self {
case .dependencyInjectionFailed:
"Failed to add swift-docc-plugin dependency to Package.swift."
case .unableToReadPackageDescription:
"Failed to read the package description."
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Script
import ArgumentParser
import Logging
struct GlobalOptions: ParsableArguments {
+17 -8
View File
@@ -1,8 +1,9 @@
import ArgumentParser
import Foundation
import Script
import Noora
import Subprocess
struct TestCommand: Script {
struct TestCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "test",
abstract: "Run swift test in a linux container.",
@@ -17,21 +18,29 @@ struct TestCommand: Script {
let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test")
let currentDirectory = FileManager.default.currentDirectoryPath
let docker = Executable(path: "/opt/homebrew/bin/docker")
noora.info("Running tests on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
do {
try await docker(
let dockerRunResult = try await Subprocess.run(
.name("docker"),
arguments: [
"run",
"-v", "\(currentDirectory):/code",
"-v", "swift-inotify-build-cache:/code/.build",
"--security-opt", "systempaths=unconfined",
"--platform", "linux/arm64",
// 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"
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
],
output: .currentStandardOutput,
error: .currentStandardError
)
if dockerRunResult.terminationStatus.isSuccess {
noora.success("All tests completed successfully.")
} catch {
} else {
noora.error("Not all tests completed successfully.")
}
}
@@ -0,0 +1,32 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Event Buffering")
struct BufferingTests {
@Test func deliversEveryEventOfABurstToALateConsumer() async throws {
try await withTempDir { dir in
let fileCount = 1000
let watcher = try Inotify()
try await watcher.addWatch(path: dir, mask: .create)
for index in 0..<fileCount {
try createFile(at: "\(dir)/file-\(index).txt")
}
try await Task.sleep(for: .milliseconds(500))
let eventTask = Task {
var events: [InotifyEvent] = []
for await event in await watcher.events {
events.append(event)
}
return events
}
try await Task.sleep(for: .seconds(1))
eventTask.cancel()
let events = await eventTask.value
#expect(events.count == fileCount, "Expected \(fileCount) CREATE events, got \(events.count)")
}
}
}
@@ -14,4 +14,24 @@ struct DirectoryResolverTests {
#expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory])
}
}
@Test func doesNotDescendIntoExcludedDirectories() async throws {
try await withTempDir { dir in
let excludedSubdirectory = "\(dir)/Excluded/Inside"
try FileManager.default.createDirectory(atPath: excludedSubdirectory, withIntermediateDirectories: true)
let directories = try await DirectoryResolver.resolve(dir, excluding: ["Excluded"])
#expect(directories.map { $0.description } == [dir])
}
}
@Test func doesNotDescendIntoDirectoriesMatchingAnExcludedPattern() async throws {
try await withTempDir { dir in
let excludedSubdirectory = "\(dir)/@eaDir/Inside"
try FileManager.default.createDirectory(atPath: excludedSubdirectory, withIntermediateDirectories: true)
let directories = try await DirectoryResolver.resolve([dir], excluding: ExclusionList(patterns: ["@*"]))
#expect(directories.map { $0.description } == [dir])
}
}
}
@@ -0,0 +1,31 @@
import CInotify
import Testing
@testable import Inotify
@Suite("Event Mask")
struct EventMaskTests {
@Test(arguments: [
(InotifyEventMask.access, UInt32(IN_ACCESS)),
(.attrib, UInt32(IN_ATTRIB)),
(.closeWrite, UInt32(IN_CLOSE_WRITE)),
(.closeNoWrite, UInt32(IN_CLOSE_NOWRITE)),
(.create, UInt32(IN_CREATE)),
(.delete, UInt32(IN_DELETE)),
(.deleteSelf, UInt32(IN_DELETE_SELF)),
(.modify, UInt32(IN_MODIFY)),
(.moveSelf, UInt32(IN_MOVE_SELF)),
(.movedFrom, UInt32(IN_MOVED_FROM)),
(.movedTo, UInt32(IN_MOVED_TO)),
(.open, UInt32(IN_OPEN)),
(.dontFollow, UInt32(IN_DONT_FOLLOW)),
(.onlyDir, UInt32(IN_ONLYDIR)),
(.oneShot, UInt32(IN_ONESHOT)),
(.isDir, UInt32(IN_ISDIR)),
(.ignored, UInt32(IN_IGNORED)),
(.queueOverflow, UInt32(IN_Q_OVERFLOW)),
(.unmount, UInt32(IN_UNMOUNT)),
] as [(InotifyEventMask, UInt32)])
func matchesTheKernelConstant(mask: InotifyEventMask, constant: UInt32) {
#expect(mask.rawValue == constant)
}
}
@@ -0,0 +1,22 @@
import Testing
@testable import Inotify
@Suite("Exclusion")
struct ExclusionTests {
@Test func excludesANameThatMatchesAPattern() async throws {
let inotify = try Inotify()
await inotify.exclude(patterns: "*.tmp", "@*")
#expect(await inotify.isExcluded("scan.tmp"))
#expect(await inotify.isExcluded("@eaDir"))
#expect(await !inotify.isExcluded("scan.pdf"))
}
@Test func excludesAnExactName() async throws {
let inotify = try Inotify()
await inotify.exclude(name: ".git")
#expect(await inotify.isExcluded(".git"))
#expect(await !inotify.isExcluded(".gitignore"))
}
}
@@ -0,0 +1,11 @@
import Testing
@testable import Inotify
@Suite("Error Description")
struct InotifyErrorTests {
@Test func describesTheStoredErrnoAndNotTheCurrentOne() {
let error = InotifyError.addWatchFailed(path: "/watched", errno: 28)
#expect(error.description == "inotify_add_watch failed for '/watched': No space left on device (errno 28)")
}
}
@@ -1,5 +1,6 @@
import Testing
import Foundation
import SystemPackage
@testable import Inotify
@Suite("Inotify Limits", .serialized)
@@ -18,6 +19,31 @@ struct InotifyLimitTests {
}
}
/// The limit counts every watch of the user, also those of other
/// processes, so it leaves room for the one watch of the second instance.
@Test func releasesTheWatchesOfATreeItCouldNotWatchCompletely() async throws {
try await withTempDir { dir in
try await withInotifyWatchLimit(of: 100, for: [.userWatches]) {
try createSubdirectorytree(at: dir, foldersPerLevel: 4, levels: 4)
let filepath = "\(dir)/new-file.txt"
let failedWatcher = try Inotify()
await #expect(throws: InotifyError.self) {
try await failedWatcher.addRecursiveWatch(forDirectory: dir, mask: .create)
}
let events = try await getEventsForTrigger(in: dir, mask: .create) { _ in
try createFile(at: filepath, contents: "hello")
}
// Deallocating the failed instance would free its watches too, so it
// must live until the second instance has added its watch.
withExtendedLifetime(failedWatcher) {}
let createEvent = events.first { $0.path.string == filepath }
#expect(createEvent != nil, "Expected a second instance to watch '\(dir)' after the failed one released its watches, got: \(events)")
}
}
}
@Test func watchesMassivSubtreesIfAllowed() async throws {
try await withTempDir { dir in
try await withInotifyWatchLimit(of: 1000) {
@@ -40,4 +66,55 @@ 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]) {
let watcher = try Inotify()
try await watcher.addWatch(path: dir, mask: .allEvents)
let overflowTask = Task { () -> (InotifyEvent?, Int) in
var received = 0
for await event in await watcher.events {
received += 1
if case .queueOverflow = event { return (event, received) }
}
return (nil, received)
}
let deadline = ContinuousClock.now + .seconds(5)
var index = 0
while !overflowTask.isCancelled, ContinuousClock.now < deadline {
try createFile(at: "\(dir)/burst-\(index).txt", contents: "hello")
index += 1
if index % 200 == 0 { await Task.yield() }
}
overflowTask.cancel()
let (overflow, received) = await overflowTask.value
#expect(overflow == .queueOverflow, "Expected a queue overflow event after \(index) file creations and \(received) received events")
}
}
}
}
@@ -0,0 +1,24 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Instance Lifecycle")
struct LifecycleTests {
@Test func aDeallocatedInstanceDoesNotStealEventsOfItsSuccessor() async throws {
try await withTempDir { dir in
let filename = "after-reuse.txt"
do {
let predecessor = try Inotify()
try await predecessor.addWatch(path: dir, mask: .create)
}
let events = try await getEventsForTrigger(
in: dir,
mask: .create,
) { try createFile(at: "\($0)/\(filename)") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
#expect(createEvent != nil, "Expected CREATE for '\(filename)', got: \(events)")
}
}
}
@@ -1,4 +1,5 @@
import Foundation
import SystemPackage
import Testing
@testable import Inotify
@@ -39,6 +40,44 @@ struct RecursiveEventTests {
}
}
@Test func ignoresFileCreationInASubfolderMatchingAnExcludedPattern() async throws {
try await withTempDir { dir in
let subDirectory = "\(dir)/@eaDir"
let filepath = "\(subDirectory)/modify-target.txt"
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
let events = try await getEventsForTrigger(
in: dir,
mask: [.create],
recursive: .recursive,
excludePatterns: ["@*"]
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
#expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)")
}
}
@Test func doesNotWatchANewSubfolderMatchingAnExcludedPattern() async throws {
try await withTempDir { dir in
let subDirectory = "\(dir)/@eaDir"
let filepath = "\(subDirectory)/modify-target.txt"
let events = try await getEventsForTrigger(
in: dir,
mask: [.create],
recursive: .withAutomaticSubtreeWatching,
excludePatterns: ["@*"]
) { _ in
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
try await Task.sleep(for: .milliseconds(400))
try createFile(at: "\(filepath)", contents: "hello")
}
#expect(events.isEmpty, "Did not expect any event, got: \(events)")
}
}
@Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws {
try await withTempDir { dir in
let subDirectory = "\(dir)/Subfolder"
@@ -58,4 +97,119 @@ struct RecursiveEventTests {
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
}
}
@Test func stopsReportingForDirectoriesMovedOutOfTheWatchedTree() async throws {
try await withTempDir { dir in
let root = "\(dir)/Root"
let outside = "\(dir)/Outside"
let movedSource = "\(root)/Moved"
let movedDestination = "\(outside)/Moved"
let filename = "created-after-move.txt"
try FileManager.default.createDirectory(atPath: movedSource, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: outside, withIntermediateDirectories: true)
let events = try await getEventsForTrigger(
in: root,
mask: [.create, .movedFrom],
recursive: .withAutomaticSubtreeWatching
) { _ in
try FileManager.default.moveItem(atPath: movedSource, toPath: movedDestination)
try await Task.sleep(for: .milliseconds(400))
try createFile(at: "\(movedDestination)/\(filename)", contents: "hello")
}
let staleEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
#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)")
}
}
/// 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 SystemPackage
enum RecursivKind {
case nonrecursive
@@ -6,15 +7,39 @@ enum RecursivKind {
case withAutomaticSubtreeWatching
}
/// The file system events an instance delivers around `trigger`.
func getEventsForTrigger(
in dir: String,
mask: InotifyEventMask,
recursive: RecursivKind = .nonrecursive,
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)
await watcher.exclude(patterns: excludePatterns)
switch recursive {
case .nonrecursive:
try await watcher.addWatch(path: dir, mask: mask)
@@ -24,13 +49,7 @@ func getEventsForTrigger(
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)
@@ -39,3 +58,55 @@ func getEventsForTrigger(
eventTask.cancel()
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
}
}
@@ -1,10 +1,28 @@
import Foundation
func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async throws {
enum InotifyLimit: String, CaseIterable {
case userWatches = "max_user_watches"
case userInstances = "max_user_instances"
case queuedEvents = "max_queued_events"
}
func withInotifyWatchLimit(
of limit: Int,
for limits: [InotifyLimit] = InotifyLimit.allCases,
_ body: () async throws -> Void
) async throws {
let confPath = URL(filePath: "/proc/sys/fs/inotify")
let filenames = ["max_user_watches", "max_user_instances", "max_queued_events"]
let filenames = limits.map(\.rawValue)
var previousLimits: [String: String] = [:]
defer {
for filename in filenames {
let filePath = confPath.appending(path: filename)
guard let previousLimit = previousLimits[filename] else { continue }
try? previousLimit.write(to: filePath, atomically: false, encoding: .utf8)
}
}
for filename in filenames {
let filePath = confPath.appending(path: filename)
let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
@@ -13,10 +31,4 @@ func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async
}
try await body()
for filename in filenames {
let filePath = confPath.appending(path: filename)
guard let previousLimit = previousLimits[filename] else { continue }
try previousLimit.write(to: filePath, atomically: false, encoding: .utf8)
}
}