Compare commits
9
Commits
10943f9ce3
...
c79691cb6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c79691cb6f | ||
|
|
134034f3ea | ||
|
|
e6ed232087 | ||
|
|
6375a23328 | ||
|
|
68f49e254c | ||
|
|
d2abc3355e | ||
|
|
dcc08eb928 | ||
|
|
39f3428bff | ||
|
|
8cedee6139 |
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "17ce26ba5c862ca674cd3ceeb43a9fe8a5c5251c5561de65e632a06d79916342",
|
||||
"originHash" : "d30dadbb08ce17a04cba957d25e81d1d76b8dc0a7bdc84a591c7af3b8eb74b85",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "noora",
|
||||
@@ -78,8 +78,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-subprocess.git",
|
||||
"state" : {
|
||||
"revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c",
|
||||
"version" : "0.3.0"
|
||||
"revision" : "b3937ab85dd32f6e9435914599c1519074769c1a",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -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-nio", from: "2.95.0"),
|
||||
.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")
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched.
|
||||
|
||||
### Automatic Subtree Watching
|
||||
|
||||
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` events with the `isDir` flag. Whenever a new subdirectory appears, a watch is installed on it automatically:
|
||||
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` and `MOVED_TO` events with the `isDir` flag. Whenever a subdirectory appears, whether created or moved in, a watch is installed on it and on its subdirectories automatically:
|
||||
|
||||
```swift
|
||||
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
@@ -75,6 +75,10 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
|
||||
This is the most convenient option when you need full coverage of a growing directory tree.
|
||||
|
||||
Items that already exist inside a directory that appears this way never produce kernel events. The library reports them as if they had just appeared, using the same kind of event (`CREATE` or `MOVED_TO`), with `synthesized` set to `true`. A synthesized event may duplicate a kernel event for the same item, so consumers that act on events should tolerate seeing an item twice.
|
||||
|
||||
When a watched directory is moved out of the tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path.
|
||||
|
||||
## 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:
|
||||
@@ -118,6 +122,8 @@ Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`.
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
let path = FilePath(path)
|
||||
resolved.append(path)
|
||||
try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in
|
||||
guard let basename = subdirectoryPath.lastComponent?.description else { return }
|
||||
guard !itemNames.contains(basename) else { return }
|
||||
resolved.append(subdirectoryPath)
|
||||
}
|
||||
try await withSubdirectories(at: path, excluding: itemNames) { resolved.append($0) }
|
||||
}
|
||||
|
||||
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)
|
||||
for try await childContent in directoryHandle.listContents() {
|
||||
guard childContent.type == .directory else { continue }
|
||||
guard let name = childContent.path.lastComponent?.string, !itemNames.contains(name) else { continue }
|
||||
try await body(childContent.path)
|
||||
if recursive {
|
||||
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
|
||||
}
|
||||
try await withSubdirectories(at: childContent.path, excluding: itemNames, body: body)
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import Dispatch
|
||||
import CInotify
|
||||
import SystemPackage
|
||||
|
||||
public actor Inotify {
|
||||
private let fd: CInt
|
||||
private var excludedItemNames: Set<String> = []
|
||||
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 continuation: AsyncStream<RawInotifyEvent>.Continuation
|
||||
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
||||
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))
|
||||
guard self.fd >= 0 else {
|
||||
throw InotifyError.initFailed(errno: cinotify_get_errno())
|
||||
}
|
||||
(self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd)
|
||||
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
|
||||
forFileDescriptor: fd,
|
||||
bufferingPolicy: bufferingPolicy
|
||||
)
|
||||
}
|
||||
|
||||
public func isExcluded(_ name: String) -> Bool {
|
||||
@@ -73,32 +87,92 @@ public actor Inotify {
|
||||
}
|
||||
|
||||
deinit {
|
||||
cinotify_deinit(self.fd)
|
||||
// The file descriptor is closed by the reader's cancel handler once
|
||||
// libdispatch has unregistered it. Closing it here would leave a
|
||||
// registration behind that a later instance reusing the descriptor
|
||||
// number could inherit, silently losing its events.
|
||||
self.eventReader.cancel()
|
||||
}
|
||||
|
||||
private func transform(_ 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 !self.excludedItemNames.contains(rawEvent.name) else { return nil }
|
||||
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
||||
self.forgetWatchInCaseTheKernelRemovedIt(event)
|
||||
self.removeWatchesInCaseADirectoryLeftTheTree(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 {
|
||||
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
|
||||
event.mask.contains(.create),
|
||||
event.mask.contains(.isDir) else {
|
||||
guard !event.synthesized,
|
||||
watches.isAutomaticSubtreeWatching(event.watchDescriptor),
|
||||
event.mask.contains(.isDir),
|
||||
let kind = Self.subtreeTrigger(in: event.mask) 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(
|
||||
of: RawInotifyEvent.self,
|
||||
bufferingPolicy: .bufferingNewest(512)
|
||||
bufferingPolicy: bufferingPolicy
|
||||
)
|
||||
|
||||
let reader = DispatchSource.makeReadSource(
|
||||
@@ -112,10 +186,11 @@ public actor Inotify {
|
||||
}
|
||||
}
|
||||
reader.setCancelHandler {
|
||||
cinotify_deinit(fd)
|
||||
continuation.finish()
|
||||
}
|
||||
reader.activate()
|
||||
|
||||
return (reader, stream)
|
||||
return (reader, stream, continuation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
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 let watchDescriptor: Int32
|
||||
public let mask: InotifyEventMask
|
||||
public let cookie: UInt32
|
||||
public let path: FilePath
|
||||
/// Whether the event was produced by the library for an item that already
|
||||
/// existed when its directory became watched, rather than by the kernel.
|
||||
public let synthesized: Bool
|
||||
|
||||
public var description: String {
|
||||
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
|
||||
@@ -20,7 +30,8 @@ extension InotifyEvent {
|
||||
watchDescriptor: rawEvent.watchDescriptor,
|
||||
mask: rawEvent.mask,
|
||||
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,
|
||||
mask: InotifyEventMask(rawValue: rawEvent.mask),
|
||||
cookie: rawEvent.cookie,
|
||||
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len)
|
||||
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len),
|
||||
synthesized: false
|
||||
))
|
||||
|
||||
offset += Self.eventSize(nameLength: rawEvent.len)
|
||||
|
||||
@@ -36,6 +36,14 @@ struct InotifyWatchManager {
|
||||
return self.watchPaths[watchDescriptor]
|
||||
}
|
||||
|
||||
/// The descriptors of the watch on `path` itself and of every watch below it.
|
||||
func descriptors(under path: String) -> [CInt] {
|
||||
let prefix = path.hasSuffix("/") ? path : path + "/"
|
||||
return self.watchPaths
|
||||
.filter { $0.value == path || $0.value.hasPrefix(prefix) }
|
||||
.map(\.key)
|
||||
}
|
||||
|
||||
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
|
||||
return self.watchMasks[watchDescriptor]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
||||
public let mask: InotifyEventMask
|
||||
public let cookie: UInt32
|
||||
public let name: String
|
||||
/// Whether the event was produced by the library for an item that already
|
||||
/// existed when its directory became watched, rather than by the kernel.
|
||||
public let synthesized: Bool
|
||||
|
||||
public var description: String {
|
||||
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
|
||||
|
||||
@@ -53,8 +53,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
"swift:latest",
|
||||
"/bin/bash", "-c", script
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
output: .currentStandardOutput,
|
||||
error: .currentStandardError
|
||||
)
|
||||
if !dockerRunResult.terminationStatus.isSuccess {
|
||||
noora.error("Documentation generation failed.")
|
||||
@@ -112,7 +112,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
.name("swift"),
|
||||
arguments: ["package", "describe", "--type", "json"],
|
||||
output: .data(limit: 10_000),
|
||||
error: .standardError
|
||||
error: .currentStandardError
|
||||
)
|
||||
|
||||
struct PackageDescription: Codable {
|
||||
@@ -178,8 +178,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
"package", "--package-path", directory.path(percentEncoded: false),
|
||||
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
output: .currentStandardOutput,
|
||||
error: .currentStandardError
|
||||
)
|
||||
if !swiftRunResult.terminationStatus.isSuccess {
|
||||
throw GenerateDocumentationError.dependencyInjectionFailed
|
||||
|
||||
@@ -30,10 +30,10 @@ struct TestCommand: AsyncParsableCommand {
|
||||
"--security-opt", "systempaths=unconfined",
|
||||
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||
"-w", "/code", "swift:latest",
|
||||
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
|
||||
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
output: .currentStandardOutput,
|
||||
error: .currentStandardError
|
||||
)
|
||||
if dockerRunResult.terminationStatus.isSuccess {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@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)")
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async throws {
|
||||
enum InotifyLimit: String, CaseIterable {
|
||||
case userWatches = "max_user_watches"
|
||||
case userInstances = "max_user_instances"
|
||||
case queuedEvents = "max_queued_events"
|
||||
}
|
||||
|
||||
func withInotifyWatchLimit(
|
||||
of limit: Int,
|
||||
for limits: [InotifyLimit] = InotifyLimit.allCases,
|
||||
_ body: () async throws -> Void
|
||||
) async throws {
|
||||
let confPath = URL(filePath: "/proc/sys/fs/inotify")
|
||||
let filenames = ["max_user_watches", "max_user_instances", "max_queued_events"]
|
||||
let filenames = limits.map(\.rawValue)
|
||||
var previousLimits: [String: String] = [:]
|
||||
|
||||
defer {
|
||||
for filename in filenames {
|
||||
let filePath = confPath.appending(path: filename)
|
||||
guard let previousLimit = previousLimits[filename] else { continue }
|
||||
try? previousLimit.write(to: filePath, atomically: false, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
for filename in filenames {
|
||||
let filePath = confPath.appending(path: filename)
|
||||
let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
|
||||
@@ -13,10 +31,4 @@ func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async
|
||||
}
|
||||
|
||||
try await body()
|
||||
|
||||
for filename in filenames {
|
||||
let filePath = confPath.appending(path: filename)
|
||||
guard let previousLimit = previousLimits[filename] else { continue }
|
||||
try previousLimit.write(to: filePath, atomically: false, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user