Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442053eae2 | ||
|
|
c79691cb6f | ||
|
|
134034f3ea | ||
|
|
e6ed232087 | ||
|
|
6375a23328 | ||
|
|
68f49e254c | ||
|
|
d2abc3355e | ||
|
|
dcc08eb928 | ||
|
|
39f3428bff | ||
|
|
8cedee6139 | ||
|
|
10943f9ce3 | ||
|
|
6927464d47 | ||
|
|
31ed16c828 | ||
|
|
ac1c86c431 | ||
|
|
4b28c293cb |
+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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+9
-5
@@ -8,23 +8,28 @@ 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.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: [
|
||||||
.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 +46,6 @@ let package = Package(
|
|||||||
name: "InotifyTaskCLI",
|
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"),
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -97,6 +101,8 @@ Use `isExcluded(_:)` to check whether a name is currently on the exclusion list.
|
|||||||
|
|
||||||
`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 |
|
||||||
@@ -118,6 +124,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:
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)\""]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -116,10 +108,11 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func packageTargets() async throws -> [(name: String, path: String)] {
|
private static func packageTargets() async throws -> [(name: String, path: String)] {
|
||||||
let packageDescription = try await Subprocess.run(
|
let packageDescriptionResult = try await Subprocess.run(
|
||||||
.name("swift"),
|
.name("swift"),
|
||||||
arguments: ["package", "describe", "--type", "json"],
|
arguments: ["package", "describe", "--type", "json"],
|
||||||
output: .data(limit: 20_000)
|
output: .data(limit: 10_000),
|
||||||
|
error: .currentStandardError
|
||||||
)
|
)
|
||||||
|
|
||||||
struct PackageDescription: Codable {
|
struct PackageDescription: Codable {
|
||||||
@@ -130,7 +123,11 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
|||||||
let path: String
|
let path: String
|
||||||
}
|
}
|
||||||
|
|
||||||
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescription.standardOutput)
|
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) }
|
return package.targets.map { ($0.name, $0.path) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,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,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])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user