9 Commits
Author SHA1 Message Date
T. R. Bernstein c79691cb6f Stop descending into excluded directories
Docs / deploy (push) Canceled after 0s
Docs / docs (push) Canceled after 0s
The resolver skipped an excluded directory in its result but still
walked its subtree, so watches were installed below names such as
`.git` or `node_modules`. Exclusion now prunes the walk.
2026-09-13 23:15:21 +02:00
T. R. Bernstein 134034f3ea Watch directories moved into the tree and report their content
Automatic subtree watching only reacted to `CREATE`, so a directory
moved in from elsewhere stayed unwatched. It is now handled like a
created one. Items that already exist in such a directory never
produce kernel events; they are reported with the same event kind
and `synthesized` set to `true`, so consumers can treat them as
newly appeared.
2026-09-13 23:13:32 +02:00
T. R. Bernstein e6ed232087 Drop watches of directories that leave the tree
A directory moved out of a watched tree kept its kernel watches, so
later changes inside it were reported under the old path. Its
watches and those of its subdirectories are now removed on
`MOVED_FROM`. Watches the kernel reports as `IGNORED` are forgotten
as well, so a reused descriptor number cannot map to a stale path.
2026-09-13 23:10:30 +02:00
T. R. Bernstein 6375a23328 Report queue overflows instead of dropping them
`IN_Q_OVERFLOW` arrives with watch descriptor -1, so the path lookup
failed and the event was silently discarded. It is now delivered
with an empty path so consumers can rescan after the kernel dropped
events.
2026-09-13 23:06:28 +02:00
T. R. Bernstein 68f49e254c Restore inotify limits even when a test body throws
The limits were only written back after a successful body, so a
failing limit test left the shared kernel at the lowered values and
every later run failed with ENOSPC. The helper now restores in a
`defer` and can lower a chosen subset of the limits.
2026-09-13 23:06:28 +02:00
T. R. Bernstein d2abc3355e Keep every event by default
Events were buffered with `bufferingNewest(512)`, so a burst of
changes silently lost all but the newest 512 events whenever the
consumer lagged. The stream now buffers without limit, and
`init(bufferingPolicy:)` lets callers choose a bounded policy.
2026-09-13 23:00:13 +02:00
T. R. Bernstein dcc08eb928 Cancel the event reader before closing the descriptor
`deinit` closed the inotify descriptor while its dispatch source was
still active. The kernel drops the epoll registration on close, but
libdispatch keeps its own; an instance created afterwards that reuses
the descriptor number could inherit that stale state and never
receive events. Roughly one test run in three lost a single event
this way.

The reader is now cancelled in `deinit` and the descriptor closed in
its cancel handler, as libdispatch requires.
2026-09-13 22:59:15 +02:00
T. R. Bernstein 39f3428bff Propagate first-pass failures in the test task
The two `swift test` passes were joined with `;`, so a failing main
pass was masked by a green limit-test pass.
2026-09-13 22:28:26 +02:00
T. R. Bernstein 8cedee6139 Support swift-subprocess 1.x
The exclusive `from: "0.3.0"` range blocked dependents that already
use swift-subprocess 1.0. Version 1.0 renamed the stdio outputs used
by the task CLI to `currentStandardOutput` and `currentStandardError`.
2026-09-13 22:28:26 +02:00
18 changed files with 332 additions and 45 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "17ce26ba5c862ca674cd3ceeb43a9fe8a5c5251c5561de65e632a06d79916342", "originHash" : "d30dadbb08ce17a04cba957d25e81d1d76b8dc0a7bdc84a591c7af3b8eb74b85",
"pins" : [ "pins" : [
{ {
"identity" : "noora", "identity" : "noora",
@@ -78,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"
} }
}, },
{ {
+1 -1
View File
@@ -15,7 +15,7 @@ let package = Package(
.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: [
+7 -1
View File
@@ -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:
+19 -9
View File
@@ -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
+87 -12
View File
@@ -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 nonisolated let eventStream: AsyncStream<RawInotifyEvent> private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> { 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)
} }
} }
+12 -1
View File
@@ -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
) )
} }
} }
+2 -1
View File
@@ -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
View File
@@ -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)\""]
@@ -53,8 +53,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
"swift:latest", "swift:latest",
"/bin/bash", "-c", script "/bin/bash", "-c", script
], ],
output: .standardOutput, output: .currentStandardOutput,
error: .standardError error: .currentStandardError
) )
if !dockerRunResult.terminationStatus.isSuccess { if !dockerRunResult.terminationStatus.isSuccess {
noora.error("Documentation generation failed.") noora.error("Documentation generation failed.")
@@ -112,7 +112,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
.name("swift"), .name("swift"),
arguments: ["package", "describe", "--type", "json"], arguments: ["package", "describe", "--type", "json"],
output: .data(limit: 10_000), output: .data(limit: 10_000),
error: .standardError error: .currentStandardError
) )
struct PackageDescription: Codable { struct PackageDescription: Codable {
@@ -178,8 +178,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
"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
], ],
output: .standardOutput, output: .currentStandardOutput,
error: .standardError error: .currentStandardError
) )
if !swiftRunResult.terminationStatus.isSuccess { if !swiftRunResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.dependencyInjectionFailed throw GenerateDocumentationError.dependencyInjectionFailed
+3 -3
View File
@@ -30,10 +30,10 @@ struct TestCommand: AsyncParsableCommand {
"--security-opt", "systempaths=unconfined", "--security-opt", "systempaths=unconfined",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(), "--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"-w", "/code", "swift:latest", "-w", "/code", "swift:latest",
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests" "/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
], ],
output: .standardOutput, output: .currentStandardOutput,
error: .standardError error: .currentStandardError
) )
if dockerRunResult.terminationStatus.isSuccess { if dockerRunResult.terminationStatus.isSuccess {
noora.success("All tests completed successfully.") noora.success("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)
}
} }