Compare commits
15
Commits
1.0.0
..
c79691cb6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c79691cb6f | ||
|
|
134034f3ea | ||
|
|
e6ed232087 | ||
|
|
6375a23328 | ||
|
|
68f49e254c | ||
|
|
d2abc3355e | ||
|
|
dcc08eb928 | ||
|
|
39f3428bff | ||
|
|
8cedee6139 | ||
|
|
10943f9ce3 | ||
|
|
6927464d47 | ||
|
|
31ed16c828 | ||
|
|
ac1c86c431 | ||
|
|
4b28c293cb | ||
|
|
55f3ca2f7b |
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Generate Docs
|
- name: Generate Docs
|
||||||
run: |
|
run: |
|
||||||
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git"
|
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git"
|
||||||
for target in Inotify TaskCLI; do
|
for target in Inotify InotifyTaskCLI; do
|
||||||
lower="${target,,}"
|
lower="${target,,}"
|
||||||
mkdir -p "./public/$lower"
|
mkdir -p "./public/$lower"
|
||||||
swift package --allow-writing-to-directory "./public/$lower" \
|
swift package --allow-writing-to-directory "./public/$lower" \
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
cp ./.github/workflows/index.tpl.html public/index.html
|
cp ./.github/workflows/index.tpl.html public/index.html
|
||||||
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html
|
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html
|
||||||
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html
|
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html
|
||||||
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
|
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
+5
-14
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20",
|
"originHash" : "d30dadbb08ce17a04cba957d25e81d1d76b8dc0a7bdc84a591c7af3b8eb74b85",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "noora",
|
"identity" : "noora",
|
||||||
@@ -33,17 +33,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
|
||||||
"version" : "1.7.0"
|
"version" : "1.7.1"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-async-algorithms",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
|
|
||||||
"version" : "1.1.3"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,8 +78,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/swiftlang/swift-subprocess.git",
|
"location" : "https://github.com/swiftlang/swift-subprocess.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c",
|
"revision" : "b3937ab85dd32f6e9435914599c1519074769c1a",
|
||||||
"version" : "0.3.0"
|
"version" : "1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-10
@@ -8,19 +8,14 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "Inotify",
|
name: "Inotify",
|
||||||
targets: ["Inotify"]
|
targets: ["Inotify"]
|
||||||
),
|
|
||||||
.executable(
|
|
||||||
name: "task",
|
|
||||||
targets: ["TaskCLI"]
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
|
|
||||||
.package(url: "https://github.com/apple/swift-log", from: "1.10.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-nio", from: "2.95.0"),
|
||||||
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
|
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
|
||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"),
|
.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")
|
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -42,15 +37,15 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "TaskCLI",
|
name: "InotifyTaskCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
|
||||||
.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: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Noora", package: "Noora")
|
.product(name: "Noora", package: "Noora")
|
||||||
]
|
],
|
||||||
|
path: "Sources/TaskCLI"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched.
|
|||||||
|
|
||||||
### Automatic Subtree Watching
|
### 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
|
```swift
|
||||||
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
@@ -75,6 +75,10 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
|
|||||||
|
|
||||||
This is the most convenient option when you need full coverage of a growing directory tree.
|
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.
|
||||||
|
|
||||||
## 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. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream:
|
||||||
@@ -118,6 +122,8 @@ Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`.
|
|||||||
|
|
||||||
Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.unmount`.
|
Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.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.
|
||||||
|
|
||||||
## Removing a Watch
|
## Removing a Watch
|
||||||
|
|
||||||
Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later:
|
Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later:
|
||||||
@@ -132,11 +138,14 @@ try inotify.removeWatch(wd)
|
|||||||
## Build Tool
|
## Build Tool
|
||||||
|
|
||||||
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS.
|
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS.
|
||||||
|
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||||
|
|
||||||
|
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift run task test
|
./task.sh test
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
|
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
|
||||||
@@ -148,7 +157,7 @@ Docker must be installed and running on your machine.
|
|||||||
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
|
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift run task generate-docs
|
./task.sh generate-docs
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the files in the newly created `public` folder.
|
Then open the files in the newly created `public` folder.
|
||||||
|
|||||||
@@ -13,24 +13,34 @@ public struct DirectoryResolver {
|
|||||||
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, recursive: true) { subdirectoryPath in
|
try await withSubdirectories(at: path, excluding: itemNames) { resolved.append($0) }
|
||||||
guard let basename = subdirectoryPath.lastComponent?.description else { return }
|
|
||||||
guard !itemNames.contains(basename) else { return }
|
|
||||||
resolved.append(subdirectoryPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
|
/// The direct children of `directory`, without the excluded names.
|
||||||
|
static func entries(of directory: FilePath, excluding itemNames: Set<String> = []) 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 !itemNames.contains(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
|
||||||
|
/// names are neither reported nor descended into.
|
||||||
|
private static func withSubdirectories(at path: FilePath, excluding itemNames: Set<String>, 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 }
|
||||||
try await body(childContent.path)
|
try await body(childContent.path)
|
||||||
if recursive {
|
try await withSubdirectories(at: childContent.path, excluding: itemNames, body: body)
|
||||||
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try await directoryHandle.close()
|
try await directoryHandle.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ 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 ``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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Excluding Directories
|
### Excluding Directories
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import CInotify
|
import CInotify
|
||||||
|
import SystemPackage
|
||||||
|
|
||||||
public actor Inotify {
|
public actor Inotify {
|
||||||
private let fd: CInt
|
private let fd: CInt
|
||||||
private var excludedItemNames: Set<String> = []
|
private var excludedItemNames: Set<String> = []
|
||||||
private var watches = InotifyWatchManager()
|
private var watches = InotifyWatchManager()
|
||||||
private var eventReader: any DispatchSourceRead
|
private nonisolated(unsafe) let eventReader: any DispatchSourceRead
|
||||||
private var eventStream: AsyncStream<RawInotifyEvent>
|
private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
|
||||||
public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation
|
||||||
|
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
||||||
self.eventStream.compactMap(self.transform(_:))
|
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<RawInotifyEvent>.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.createEventReader(forFileDescriptor: fd)
|
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
|
||||||
|
forFileDescriptor: fd,
|
||||||
|
bufferingPolicy: bufferingPolicy
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isExcluded(_ name: String) -> Bool {
|
public func isExcluded(_ name: String) -> Bool {
|
||||||
@@ -73,32 +87,92 @@ public actor Inotify {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
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(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
|
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
|
||||||
|
if rawEvent.mask.contains(.queueOverflow) {
|
||||||
|
return InotifyEvent(from: rawEvent, inDirectory: "")
|
||||||
|
}
|
||||||
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.excludedItemNames.contains(rawEvent.name) else { return nil }
|
||||||
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
||||||
|
self.forgetWatchInCaseTheKernelRemovedIt(event)
|
||||||
|
self.removeWatchesInCaseADirectoryLeftTheTree(event)
|
||||||
await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
|
await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
|
||||||
return InotifyEvent.init(from: rawEvent, inDirectory: path)
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: InotifyEvent) {
|
||||||
|
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: InotifyEvent) {
|
||||||
|
guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return }
|
||||||
|
for wd in self.watches.descriptors(under: event.path.string) {
|
||||||
|
inotify_rm_watch(self.fd, wd)
|
||||||
|
self.watches.remove(forId: wd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
|
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
|
||||||
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
|
guard !event.synthesized,
|
||||||
event.mask.contains(.create),
|
watches.isAutomaticSubtreeWatching(event.watchDescriptor),
|
||||||
event.mask.contains(.isDir) else {
|
event.mask.contains(.isDir),
|
||||||
|
let kind = Self.subtreeTrigger(in: event.mask) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 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)
|
guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return }
|
||||||
|
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createEventReader(forFileDescriptor fd: CInt) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
|
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.excludedItemNames) else { continue }
|
||||||
|
for entry in entries {
|
||||||
|
let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind
|
||||||
|
self.continuation.yield(RawInotifyEvent(
|
||||||
|
watchDescriptor: wd,
|
||||||
|
mask: mask,
|
||||||
|
cookie: cookie,
|
||||||
|
name: entry.name,
|
||||||
|
synthesized: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createEventReader(
|
||||||
|
forFileDescriptor fd: CInt,
|
||||||
|
bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy
|
||||||
|
) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>, AsyncStream<RawInotifyEvent>.Continuation) {
|
||||||
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
|
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
|
||||||
of: RawInotifyEvent.self,
|
of: RawInotifyEvent.self,
|
||||||
bufferingPolicy: .bufferingNewest(512)
|
bufferingPolicy: bufferingPolicy
|
||||||
)
|
)
|
||||||
|
|
||||||
let reader = DispatchSource.makeReadSource(
|
let reader = DispatchSource.makeReadSource(
|
||||||
@@ -112,10 +186,11 @@ public actor Inotify {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.setCancelHandler {
|
reader.setCancelHandler {
|
||||||
|
cinotify_deinit(fd)
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
reader.activate()
|
reader.activate()
|
||||||
|
|
||||||
return (reader, stream)
|
return (reader, stream, continuation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import SystemPackage
|
import SystemPackage
|
||||||
|
|
||||||
|
/// A filesystem event delivered by an ``Inotify`` instance.
|
||||||
|
///
|
||||||
|
/// When the kernel's event queue overflows, it drops events and reports a
|
||||||
|
/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``.
|
||||||
|
/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and
|
||||||
|
/// its ``path`` is empty. Consumers that must not miss changes should
|
||||||
|
/// rescan the watched trees when they receive one.
|
||||||
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
||||||
public let watchDescriptor: Int32
|
public let watchDescriptor: Int32
|
||||||
public let mask: InotifyEventMask
|
public let mask: InotifyEventMask
|
||||||
public let cookie: UInt32
|
public let cookie: UInt32
|
||||||
public let path: FilePath
|
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 {
|
public var description: String {
|
||||||
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
|
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
|
||||||
@@ -20,7 +30,8 @@ extension InotifyEvent {
|
|||||||
watchDescriptor: rawEvent.watchDescriptor,
|
watchDescriptor: rawEvent.watchDescriptor,
|
||||||
mask: rawEvent.mask,
|
mask: rawEvent.mask,
|
||||||
cookie: rawEvent.cookie,
|
cookie: rawEvent.cookie,
|
||||||
path: dirPath.appending(rawEvent.name)
|
path: dirPath.appending(rawEvent.name),
|
||||||
|
synthesized: rawEvent.synthesized
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ struct InotifyEventParser {
|
|||||||
watchDescriptor: rawEvent.wd,
|
watchDescriptor: rawEvent.wd,
|
||||||
mask: InotifyEventMask(rawValue: rawEvent.mask),
|
mask: InotifyEventMask(rawValue: rawEvent.mask),
|
||||||
cookie: rawEvent.cookie,
|
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)
|
offset += Self.eventSize(nameLength: rawEvent.len)
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ struct InotifyWatchManager {
|
|||||||
return self.watchPaths[watchDescriptor]
|
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? {
|
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
|
||||||
return self.watchMasks[watchDescriptor]
|
return self.watchMasks[watchDescriptor]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
|||||||
public let mask: InotifyEventMask
|
public let mask: InotifyEventMask
|
||||||
public let cookie: UInt32
|
public let cookie: UInt32
|
||||||
public let name: String
|
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 {
|
public var description: String {
|
||||||
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
|
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
|
||||||
|
|||||||
@@ -3,27 +3,15 @@ import _NIOFileSystem
|
|||||||
public struct DoccFinder {
|
public struct DoccFinder {
|
||||||
static let fileManager = FileSystem.shared
|
static let fileManager = FileSystem.shared
|
||||||
|
|
||||||
public static func getTargetsWithDocumentation(at paths: String...) async throws -> [String] {
|
public static func hasDoccFolder(at path: String) async throws -> Bool {
|
||||||
try await Self.getTargetsWithDocumentation(at: paths)
|
let itemPath = FilePath(path)
|
||||||
}
|
var hasDoccFolder = false
|
||||||
|
|
||||||
static func getTargetsWithDocumentation(at paths: [String]) async throws -> [String] {
|
try await withSubdirectories(at: itemPath) { subdirectory in
|
||||||
var resolved: [String] = []
|
guard subdirectory.description.hasSuffix(".docc") else { return }
|
||||||
|
hasDoccFolder = true
|
||||||
for path in paths {
|
|
||||||
let itemPath = FilePath(path)
|
|
||||||
|
|
||||||
try await withSubdirectories(at: itemPath) { targetPath in
|
|
||||||
print("Target path is", targetPath.description)
|
|
||||||
try await withSubdirectories(at: targetPath) { subdirectory in
|
|
||||||
guard subdirectory.description.hasSuffix(".docc") else { return }
|
|
||||||
guard let target = targetPath.lastComponent?.description else { return }
|
|
||||||
resolved.append(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return hasDoccFolder
|
||||||
return resolved
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {
|
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
struct Docker {
|
||||||
|
static func getLinuxPlatformStringWithHostArchitecture() -> String {
|
||||||
|
#if arch(x86_64)
|
||||||
|
return "linux/amd64"
|
||||||
|
#else
|
||||||
|
return "linux/arm64"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import AsyncAlgorithms
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
import Noora
|
import Noora
|
||||||
@@ -43,28 +42,21 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
let script = Self.makeRunScript(for: targets)
|
let script = Self.makeRunScript(for: targets)
|
||||||
|
|
||||||
logger.debug("Container script", metadata: ["script": "\(script)"])
|
logger.debug("Container script", metadata: ["script": "\(script)"])
|
||||||
let dockerResult = try await Subprocess.run(
|
let dockerRunResult = try await Subprocess.run(
|
||||||
.name("docker"),
|
.name("docker"),
|
||||||
arguments: [
|
arguments: [
|
||||||
"run", "--rm",
|
"run", "--rm",
|
||||||
"-v", "\(tempDirectory.path(percentEncoded: false)):/code",
|
"-v", "\(tempDirectory.path):/code",
|
||||||
"--platform", "linux/arm64",
|
"-v", "swift-inotify-build-cache:/code/.build",
|
||||||
|
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||||
"-w", "/code",
|
"-w", "/code",
|
||||||
"swift:latest",
|
"swift:latest",
|
||||||
"/bin/bash", "-c", script,
|
"/bin/bash", "-c", script
|
||||||
],
|
],
|
||||||
preferredBufferSize: 10,
|
output: .currentStandardOutput,
|
||||||
) { execution, standardInput, standardOutput, standardError in
|
error: .currentStandardError
|
||||||
print("")
|
)
|
||||||
let stdout = standardOutput.lines()
|
if !dockerRunResult.terminationStatus.isSuccess {
|
||||||
let stderr = standardError.lines()
|
|
||||||
for try await line in merge(stdout, stderr) {
|
|
||||||
noora.passthrough("\(line)")
|
|
||||||
}
|
|
||||||
print("")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard dockerResult.terminationStatus.isSuccess else {
|
|
||||||
noora.error("Documentation generation failed.")
|
noora.error("Documentation generation failed.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,7 +82,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
|
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
|
||||||
("{{project.links}}", """
|
("{{project.links}}", """
|
||||||
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
|
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
|
||||||
<li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li>
|
<li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>
|
||||||
"""),
|
"""),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -106,9 +98,37 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func targets(for projectDirectory: URL) async throws -> [String] {
|
private static func targets(for projectDirectory: URL) async throws -> [String] {
|
||||||
let sourcesDirectory = projectDirectory.appending(path: "Sources").path
|
let packages = try await Self.packageTargets()
|
||||||
let testsDirectory = projectDirectory.appending(path: "Tests").path
|
var packagesWithDoccFolder: [(name: String, path: String)] = []
|
||||||
return try await DoccFinder.getTargetsWithDocumentation(at: sourcesDirectory, testsDirectory)
|
for package in packages {
|
||||||
|
guard try await DoccFinder.hasDoccFolder(at: package.path) else { continue }
|
||||||
|
packagesWithDoccFolder.append(package)
|
||||||
|
}
|
||||||
|
return packagesWithDoccFolder.map { $0.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func packageTargets() async throws -> [(name: String, path: String)] {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
struct Target: Codable {
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeRunScript(for targets: [String]) -> String {
|
private static func makeRunScript(for targets: [String]) -> String {
|
||||||
@@ -152,15 +172,16 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
// MARK: - Dependency Injection
|
// MARK: - Dependency Injection
|
||||||
|
|
||||||
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
|
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
|
||||||
let result = try await Subprocess.run(
|
let swiftRunResult = try await Subprocess.run(
|
||||||
.name("swift"),
|
.name("swift"),
|
||||||
arguments: [
|
arguments: [
|
||||||
"package", "--package-path", directory.path(percentEncoded: false),
|
"package", "--package-path", directory.path(percentEncoded: false),
|
||||||
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
|
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
|
||||||
],
|
],
|
||||||
) { _ in }
|
output: .currentStandardOutput,
|
||||||
|
error: .currentStandardError
|
||||||
guard result.terminationStatus.isSuccess else {
|
)
|
||||||
|
if !swiftRunResult.terminationStatus.isSuccess {
|
||||||
throw GenerateDocumentationError.dependencyInjectionFailed
|
throw GenerateDocumentationError.dependencyInjectionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +191,14 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
enum GenerateDocumentationError: Error, CustomStringConvertible {
|
enum GenerateDocumentationError: Error, CustomStringConvertible {
|
||||||
case dependencyInjectionFailed
|
case dependencyInjectionFailed
|
||||||
|
case unableToReadPackageDescription
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dependencyInjectionFailed:
|
case .dependencyInjectionFailed:
|
||||||
"Failed to add swift-docc-plugin dependency to Package.swift."
|
"Failed to add swift-docc-plugin dependency to Package.swift."
|
||||||
|
case .unableToReadPackageDescription:
|
||||||
|
"Failed to read the package description."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ``TaskCLI``
|
# ``InotifyTaskCLI``
|
||||||
|
|
||||||
The build tool for the Swift Inotify project.
|
The build tool for the Swift Inotify project.
|
||||||
|
|
||||||
@@ -6,10 +6,14 @@ The build tool for the Swift Inotify project.
|
|||||||
|
|
||||||
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS.
|
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS.
|
||||||
|
|
||||||
|
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||||
|
|
||||||
|
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
|
||||||
### Running the Tests
|
### Running the Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift run task test
|
./task.sh test
|
||||||
```
|
```
|
||||||
|
|
||||||
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
|
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
|
||||||
@@ -22,7 +26,7 @@ The container is started with `--security-opt systempaths=unconfined` so that th
|
|||||||
### Generating Documentation
|
### Generating Documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift run task generate-documentation
|
./task.sh generate-documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.
|
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import AsyncAlgorithms
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Subprocess
|
|
||||||
import Noora
|
import Noora
|
||||||
|
import Subprocess
|
||||||
|
|
||||||
struct TestCommand: AsyncParsableCommand {
|
struct TestCommand: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
@@ -22,21 +21,21 @@ struct TestCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
noora.info("Running tests on Linux.")
|
noora.info("Running tests on Linux.")
|
||||||
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
|
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
|
||||||
async let monitorResult = Subprocess.run(
|
let dockerRunResult = try await Subprocess.run(
|
||||||
.name("docker"),
|
.name("docker"),
|
||||||
arguments: ["run", "-v", "\(currentDirectory):/code", "--security-opt", "systempaths=unconfined", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"],
|
arguments: [
|
||||||
preferredBufferSize: 10,
|
"run",
|
||||||
) { execution, standardInput, standardOutput, standardError in
|
"-v", "\(currentDirectory):/code",
|
||||||
print("")
|
"-v", "swift-inotify-build-cache:/code/.build",
|
||||||
let stdout = standardOutput.lines()
|
"--security-opt", "systempaths=unconfined",
|
||||||
let stderr = standardError.lines()
|
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||||
for try await line in merge(stdout, stderr) {
|
"-w", "/code", "swift:latest",
|
||||||
noora.passthrough("\(line)")
|
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
|
||||||
}
|
],
|
||||||
print("")
|
output: .currentStandardOutput,
|
||||||
}
|
error: .currentStandardError
|
||||||
|
)
|
||||||
if (try await monitorResult.terminationStatus.isSuccess) {
|
if dockerRunResult.terminationStatus.isSuccess {
|
||||||
noora.success("All tests completed successfully.")
|
noora.success("All tests completed successfully.")
|
||||||
} else {
|
} else {
|
||||||
noora.error("Not all tests completed successfully.")
|
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,14 @@ struct DirectoryResolverTests {
|
|||||||
#expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory])
|
#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])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,35 @@ struct InotifyLimitTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 event.mask.contains(.queueOverflow) { 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 != nil, "Expected a queue overflow event after \(index) file creations and \(received) received events")
|
||||||
|
#expect(overflow?.watchDescriptor == -1)
|
||||||
|
#expect(overflow?.path == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,4 +58,66 @@ struct RecursiveEventTests {
|
|||||||
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
#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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
import Foundation
|
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 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] = [:]
|
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 {
|
for filename in filenames {
|
||||||
let filePath = confPath.appending(path: filename)
|
let filePath = confPath.appending(path: filename)
|
||||||
let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
|
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()
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
# task - Run the package's TaskCLI target via a transient "task" product.
|
||||||
|
#
|
||||||
|
# Works around https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
# by temporarily adding an executable product named "task" to Package.swift,
|
||||||
|
# running it with `swift run`, and restoring the original manifest afterwards.
|
||||||
|
#
|
||||||
|
# Usage: task [arguments...]
|
||||||
|
#
|
||||||
|
# The script auto-detects the package name from Package.swift and expects an
|
||||||
|
# executable target named "<PackageName>TaskCLI" to exist.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Resolve the package root (search upward for Package.swift) -----------
|
||||||
|
|
||||||
|
package_root="${PWD}"
|
||||||
|
while [[ ! -f "${package_root}/Package.swift" ]]; do
|
||||||
|
package_root="${package_root:h}" # zsh dirname
|
||||||
|
if [[ "${package_root}" == "/" ]]; then
|
||||||
|
echo "error: Could not find Package.swift in any parent directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
manifest="${package_root}/Package.swift"
|
||||||
|
backup="${manifest}.task-backup"
|
||||||
|
|
||||||
|
# --- Extract the package name ---------------------------------------------
|
||||||
|
|
||||||
|
package_name=$(sed -n 's/^.*name:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -1)
|
||||||
|
if [[ -z "${package_name}" ]]; then
|
||||||
|
echo "error: Could not determine package name from ${manifest}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_name="${package_name}TaskCLI"
|
||||||
|
|
||||||
|
# --- Cleanup trap (runs on EXIT — covers success, failure, signals) -------
|
||||||
|
|
||||||
|
function cleanup {
|
||||||
|
if [[ -f "${backup}" ]]; then
|
||||||
|
mv -f "${backup}" "${manifest}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# --- Inject the transient "task" product ----------------------------------
|
||||||
|
|
||||||
|
cp -f "${manifest}" "${backup}"
|
||||||
|
|
||||||
|
swift package --package-path "${package_root}" \
|
||||||
|
add-product task --type executable --targets "${target_name}"
|
||||||
|
|
||||||
|
# --- Run it (forward all script arguments) --------------------------------
|
||||||
|
|
||||||
|
swift run --package-path "${package_root}" task "$@"
|
||||||
Reference in New Issue
Block a user