Compare commits
7
Commits
c79691cb6f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c037302c01 | ||
|
|
3215d2eb5a | ||
|
|
21f096aede | ||
|
|
3c852a565c | ||
|
|
f01e16e864 | ||
|
|
8af25be549 | ||
|
|
442053eae2 |
@@ -26,6 +26,7 @@ jobs:
|
|||||||
- name: Set up Swift
|
- name: Set up Swift
|
||||||
uses: swift-actions/setup-swift@v3
|
uses: swift-actions/setup-swift@v3
|
||||||
with:
|
with:
|
||||||
|
swift-version: "6.3"
|
||||||
skip-verify-signature: true
|
skip-verify-signature: true
|
||||||
- name: Generate Docs
|
- name: Generate Docs
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+7
-1
@@ -8,7 +8,11 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "Inotify",
|
name: "Inotify",
|
||||||
targets: ["Inotify"]
|
targets: ["Inotify"]
|
||||||
)
|
),
|
||||||
|
.library(
|
||||||
|
name: "InotifyMask",
|
||||||
|
targets: ["InotifyMask"]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
|
||||||
@@ -20,10 +24,12 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.systemLibrary(name: "CInotify"),
|
.systemLibrary(name: "CInotify"),
|
||||||
|
.target(name: "InotifyMask"),
|
||||||
.target(
|
.target(
|
||||||
name: "Inotify",
|
name: "Inotify",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CInotify",
|
"CInotify",
|
||||||
|
"InotifyMask",
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "_NIOFileSystem", package: "swift-nio"),
|
.product(name: "_NIOFileSystem", package: "swift-nio"),
|
||||||
.product(name: "SystemPackage", package: "swift-system")
|
.product(name: "SystemPackage", package: "swift-system")
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ 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.")
|
||||||
|
case .watchFailed(let path, let error):
|
||||||
|
print("Changes below \(path) go unreported: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,9 +86,11 @@ 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. 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
|
```swift
|
||||||
let inotify = try Inotify()
|
let inotify = try Inotify()
|
||||||
@@ -89,18 +98,23 @@ let inotify = try Inotify()
|
|||||||
// Ignore version-control and build directories
|
// Ignore version-control and build directories
|
||||||
await inotify.exclude(names: ".git", "node_modules", ".build")
|
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(
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
forDirectory: "/home/user/project",
|
forDirectory: "/home/user/project",
|
||||||
mask: [.create, .modify, .delete]
|
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
|
## Event Masks
|
||||||
|
|
||||||
`InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely.
|
`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 |
|
| Mask | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `.access` | File was read |
|
| `.access` | File was read |
|
||||||
@@ -120,9 +134,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
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <sys/inotify.h>
|
#include <sys/inotify.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
static inline int cinotify_deinit(int fd) {
|
static inline int cinotify_deinit(int fd) {
|
||||||
return close(fd);
|
return close(fd);
|
||||||
@@ -14,12 +15,8 @@ static inline int cinotify_get_errno(void) {
|
|||||||
return errno;
|
return errno;
|
||||||
}
|
}
|
||||||
|
|
||||||
static inline char* get_error_message() {
|
static inline char* cinotify_error_message(int error_number) {
|
||||||
int error_number = errno;
|
return strerror(error_number);
|
||||||
errno = 0;
|
|
||||||
char* error_message = strerror(error_number);
|
|
||||||
if (errno > 0) return NULL;
|
|
||||||
return error_message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,31 +1,71 @@
|
|||||||
|
import CInotify
|
||||||
import _NIOFileSystem
|
import _NIOFileSystem
|
||||||
|
|
||||||
public struct DirectoryResolver {
|
public struct DirectoryResolver {
|
||||||
static let fileManager = FileSystem.shared
|
static let fileManager = FileSystem.shared
|
||||||
|
|
||||||
public static func resolve(_ paths: String..., excluding itemNames: Set<String> = []) async throws -> [FilePath] {
|
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] = []
|
var resolved: [FilePath] = []
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let path = FilePath(path)
|
let path = FilePath(path)
|
||||||
resolved.append(path)
|
resolved.append(path)
|
||||||
try await withSubdirectories(at: path, excluding: itemNames) { resolved.append($0) }
|
try await withSubdirectories(at: path, excluding: exclusions) { resolved.append($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The direct children of `directory`, without the excluded names.
|
/// Resolves `path` like ``resolve(_:excluding:)``, but a directory that
|
||||||
static func entries(of directory: FilePath, excluding itemNames: Set<String> = []) async throws -> [(name: String, isDirectory: Bool)] {
|
/// 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)
|
let directoryHandle = try await fileManager.openDirectory(atPath: directory)
|
||||||
var entries: [(name: String, isDirectory: Bool)] = []
|
var entries: [(name: String, isDirectory: Bool)] = []
|
||||||
for try await childContent in directoryHandle.listContents() {
|
for try await childContent in directoryHandle.listContents() {
|
||||||
guard let name = childContent.path.lastComponent?.string else { continue }
|
guard let name = childContent.path.lastComponent?.string else { continue }
|
||||||
guard !itemNames.contains(name) else { continue }
|
guard !exclusions.excludes(name) else { continue }
|
||||||
entries.append((name: name, isDirectory: childContent.type == .directory))
|
entries.append((name: name, isDirectory: childContent.type == .directory))
|
||||||
}
|
}
|
||||||
try await directoryHandle.close()
|
try await directoryHandle.close()
|
||||||
@@ -33,15 +73,21 @@ public struct DirectoryResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calls `body` for every subdirectory below `path`, depth first. Excluded
|
/// Calls `body` for every subdirectory below `path`, depth first. Excluded
|
||||||
/// names are neither reported nor descended into.
|
/// directories are neither reported nor descended into.
|
||||||
private static func withSubdirectories(at path: FilePath, excluding itemNames: Set<String>, body: (FilePath) async throws -> Void) async throws {
|
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)
|
let directoryHandle = try await fileManager.openDirectory(atPath: path)
|
||||||
for try await childContent in directoryHandle.listContents() {
|
for try await childContent in directoryHandle.listContents() {
|
||||||
guard childContent.type == .directory else { continue }
|
guard childContent.type == .directory else { continue }
|
||||||
guard let name = childContent.path.lastComponent?.string, !itemNames.contains(name) else { continue }
|
guard let name = childContent.path.lastComponent?.string, !exclusions.excludes(name) else { continue }
|
||||||
try await body(childContent.path)
|
try await body(childContent.path)
|
||||||
try await withSubdirectories(at: childContent.path, excluding: itemNames, body: body)
|
try await withSubdirectories(at: childContent.path, excluding: exclusions, body: body)
|
||||||
}
|
}
|
||||||
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)] = []
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,21 @@ 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, 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()
|
||||||
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")
|
||||||
|
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/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.
|
- ``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.
|
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``
|
- ``Inotify/Inotify``
|
||||||
- ``InotifyEvent``
|
- ``InotifyEvent``
|
||||||
|
- ``FileSystemEvent``
|
||||||
- ``InotifyEventMask``
|
- ``InotifyEventMask``
|
||||||
|
|
||||||
### Articles
|
### 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
|
### Automatic Subtree Watching
|
||||||
|
|
||||||
@@ -31,17 +31,39 @@ 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.
|
||||||
|
|
||||||
|
#### 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:)`` **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
|
```swift
|
||||||
let inotify = try Inotify()
|
let inotify = try Inotify()
|
||||||
await inotify.exclude(names: ".git", "node_modules", ".build")
|
await inotify.exclude(names: ".git", "node_modules", ".build")
|
||||||
|
await inotify.exclude(patterns: ".*", "*.tmp")
|
||||||
|
|
||||||
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
forDirectory: "/home/user/project",
|
forDirectory: "/home/user/project",
|
||||||
@@ -49,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
|
### Choosing the Right Method
|
||||||
|
|
||||||
|
|||||||
+124
-33
@@ -4,12 +4,12 @@ import SystemPackage
|
|||||||
|
|
||||||
public actor Inotify {
|
public actor Inotify {
|
||||||
private let fd: CInt
|
private let fd: CInt
|
||||||
private var excludedItemNames: Set<String> = []
|
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,23 +22,25 @@ 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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an item with this name is skipped, by an excluded name or
|
||||||
|
/// an excluded pattern.
|
||||||
public func isExcluded(_ name: String) -> Bool {
|
public func isExcluded(_ name: String) -> Bool {
|
||||||
self.excludedItemNames.contains(name)
|
self.exclusions.excludes(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func exclude(name: String) {
|
public func exclude(name: String) {
|
||||||
self.excludedItemNames.insert(name)
|
self.exclusions.add(name: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func exclude(names: String...) {
|
public func exclude(names: String...) {
|
||||||
@@ -47,12 +49,33 @@ public actor Inotify {
|
|||||||
|
|
||||||
public func exclude(names: [String]) {
|
public func exclude(names: [String]) {
|
||||||
for name in names {
|
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
|
@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())
|
||||||
@@ -61,13 +84,19 @@ public actor Inotify {
|
|||||||
return wd
|
return wd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Watches `path` and every directory below it, or throws and leaves no
|
||||||
|
/// watch behind when one of them cannot be watched.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
|
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] = []
|
var result: [CInt] = []
|
||||||
|
do {
|
||||||
for path in directoryPaths {
|
for path in directoryPaths {
|
||||||
let wd = try self.addWatch(path: path.string, mask: mask)
|
result.append(try self.addWatch(path: path.string, mask: mask))
|
||||||
result.append(wd)
|
}
|
||||||
|
} catch {
|
||||||
|
self.dropWatches(result)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -79,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())
|
||||||
}
|
}
|
||||||
@@ -94,24 +123,31 @@ 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 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.excludedItemNames.contains(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)
|
||||||
}
|
}
|
||||||
@@ -119,27 +155,62 @@ 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) {
|
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)
|
inotify_rm_watch(self.fd, wd)
|
||||||
self.watches.remove(forId: wd)
|
self.watches.remove(forId: wd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
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 }
|
||||||
@@ -152,26 +223,39 @@ public actor Inotify {
|
|||||||
private func synthesizeEvents(forContentOfWatches wds: [CInt], kind: InotifyEventMask, cookie: UInt32) async {
|
private func synthesizeEvents(forContentOfWatches wds: [CInt], kind: InotifyEventMask, cookie: UInt32) async {
|
||||||
for wd in wds {
|
for wd in wds {
|
||||||
guard let directory = self.watches.path(forId: wd) else { continue }
|
guard let directory = self.watches.path(forId: wd) else { continue }
|
||||||
guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.excludedItemNames) 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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,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 {
|
||||||
@@ -193,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +15,13 @@ 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))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readableErrno(_ code: Int32) -> String {
|
private func readableErrno(_ code: Int32) -> String {
|
||||||
if let cStr = get_error_message() {
|
guard let message = cinotify_error_message(code) else { return "errno \(code)" }
|
||||||
return String(cString: cStr) + " (errno \(code))"
|
return String(cString: message) + " (errno \(code))"
|
||||||
}
|
|
||||||
return "errno \(code)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,20 @@
|
|||||||
import SystemPackage
|
import SystemPackage
|
||||||
|
|
||||||
/// A filesystem event delivered by an ``Inotify`` instance.
|
/// What an ``Inotify`` instance delivers: a change to a watched item, or a
|
||||||
///
|
/// condition that affects which changes it can deliver.
|
||||||
/// When the kernel's event queue overflows, it drops events and reports a
|
public enum InotifyEvent: Sendable, Hashable {
|
||||||
/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``.
|
/// A change to a watched file or directory.
|
||||||
/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and
|
case fileSystem(FileSystemEvent)
|
||||||
/// its ``path`` is empty. Consumers that must not miss changes should
|
/// The kernel's event queue was full, so it dropped events. Consumers
|
||||||
/// rescan the watched trees when they receive one.
|
/// that must not miss changes should rescan the watched trees.
|
||||||
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
case queueOverflow
|
||||||
public let watchDescriptor: Int32
|
/// A directory that appeared in a tree watched with automatic subtree
|
||||||
public let mask: InotifyEventMask
|
/// watching could not be watched, so changes below it go unreported.
|
||||||
public let cookie: UInt32
|
///
|
||||||
public let path: FilePath
|
/// It follows the event of the directory whose appearance made the
|
||||||
/// Whether the event was produced by the library for an item that already
|
/// library extend the watch. A reached watch limit ends the extension,
|
||||||
/// existed when its directory became watched, rather than by the kernel.
|
/// so the directories after the first failed one are not reported
|
||||||
public let synthesized: Bool
|
/// separately. A directory that vanished before it could be watched is
|
||||||
|
/// not reported, since its removal arrives as an event of its own.
|
||||||
public var description: String {
|
case watchFailed(path: FilePath, error: InotifyError)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -24,4 +24,14 @@ struct DirectoryResolverTests {
|
|||||||
#expect(directories.map { $0.description } == [dir])
|
#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 Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SystemPackage
|
||||||
@testable import Inotify
|
@testable import Inotify
|
||||||
|
|
||||||
@Suite("Inotify Limits", .serialized)
|
@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 {
|
@Test func watchesMassivSubtreesIfAllowed() async throws {
|
||||||
try await withTempDir { dir in
|
try await withTempDir { dir in
|
||||||
try await withInotifyWatchLimit(of: 1000) {
|
try await withInotifyWatchLimit(of: 1000) {
|
||||||
@@ -41,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]) {
|
||||||
@@ -50,7 +98,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 +113,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 == "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SystemPackage
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Inotify
|
@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 {
|
@Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws {
|
||||||
try await withTempDir { dir in
|
try await withTempDir { dir in
|
||||||
let subDirectory = "\(dir)/Subfolder"
|
let subDirectory = "\(dir)/Subfolder"
|
||||||
@@ -120,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
|
||||||
@@ -6,15 +7,39 @@ 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,
|
||||||
recursive: RecursivKind = .nonrecursive,
|
recursive: RecursivKind = .nonrecursive,
|
||||||
exclude: [String] = [],
|
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,
|
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)
|
||||||
|
await watcher.exclude(patterns: excludePatterns)
|
||||||
switch recursive {
|
switch recursive {
|
||||||
case .nonrecursive:
|
case .nonrecursive:
|
||||||
try await watcher.addWatch(path: dir, mask: mask)
|
try await watcher.addWatch(path: dir, mask: mask)
|
||||||
@@ -24,13 +49,7 @@ func getEventsForTrigger(
|
|||||||
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)
|
||||||
@@ -39,3 +58,55 @@ func getEventsForTrigger(
|
|||||||
eventTask.cancel()
|
eventTask.cancel()
|
||||||
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user