Report the directories a growing tree could not watch
Extending an automatically watched tree to a new directory swallowed every error, so a reached watch limit or an unreadable directory left part of the tree unwatched without any sign. No call of the consumer is running at that moment, so the failures now arrive in the event stream as InotifyEvent.watchFailed, after the event that triggered the extension. The library watches what it can first: a reached limit ends the attempt, an unreadable directory is skipped with its subtree, and a directory that vanished in between is not reported. The buffer now carries the library's own events next to the kernel's, which keeps them in order and hides the stream's element type. The test runner drops root's DAC capabilities so that an unreadable directory can be tested.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
@testable import Inotify
|
||||
|
||||
@Suite("Inotify Limits", .serialized)
|
||||
@@ -66,6 +67,28 @@ struct InotifyLimitTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree that grows is larger than the limit, so the extension fails
|
||||
/// part way; the watches that exist keep working.
|
||||
@Test func reportsTheDirectoriesItCannotWatchWhenATreeGrows() async throws {
|
||||
try await withTempDir { dir in
|
||||
try await withInotifyWatchLimit(of: 100, for: [.userWatches]) {
|
||||
let grown = "\(dir)/Grown"
|
||||
let filepath = "\(dir)/after-failure.txt"
|
||||
let watcher = try Inotify()
|
||||
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: [.create])
|
||||
try createSubdirectorytree(at: grown, foldersPerLevel: 3, levels: 4)
|
||||
let untilFailure = await collectEvents(of: watcher, until: { $0.watchFailure != nil }, timeout: .seconds(5))
|
||||
try createFile(at: filepath, contents: "hello")
|
||||
let afterFailure = await collectEvents(of: watcher, until: { $0.fileSystemEvent?.path.string == filepath }, timeout: .seconds(5))
|
||||
|
||||
let failure = untilFailure.last?.watchFailure
|
||||
#expect(failure?.error == .addWatchFailed(path: failure?.path.string ?? "", errno: ENOSPC), "Expected a watch failure with ENOSPC, got: \(untilFailure.suffix(3))")
|
||||
#expect(failure?.path.starts(with: FilePath(grown)) == true, "Expected the failed directory below '\(grown)', got: \(String(describing: failure))")
|
||||
#expect(afterFailure.last?.fileSystemEvent?.path.string == filepath, "Expected CREATE for '\(filepath)' after the failure, got: \(afterFailure.suffix(3))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func reportsQueueOverflowInsteadOfDroppingIt() async throws {
|
||||
try await withTempDir { dir in
|
||||
try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
import Testing
|
||||
@testable import Inotify
|
||||
|
||||
@@ -158,4 +159,57 @@ struct RecursiveEventTests {
|
||||
#expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Needs a process that directory permissions apply to; the test runner
|
||||
/// drops root's override capabilities for that.
|
||||
@Test func reportsANewDirectoryItCannotReadAndWatchesItsSiblings() async throws {
|
||||
try await withTempDir { dir in
|
||||
let root = "\(dir)/Root"
|
||||
let treeSource = "\(dir)/Outside/Grown"
|
||||
let treeDestination = "\(root)/Grown"
|
||||
let locked = "\(treeDestination)/Locked"
|
||||
let filepath = "\(treeDestination)/Open/created.txt"
|
||||
try FileManager.default.createDirectory(atPath: "\(treeSource)/Locked", withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: "\(treeSource)/Open", withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: "\(treeSource)/Locked")
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked) }
|
||||
|
||||
let events = try await getInotifyEventsForTrigger(
|
||||
in: root,
|
||||
mask: [.create, .movedTo],
|
||||
recursive: .withAutomaticSubtreeWatching
|
||||
) { _ in
|
||||
try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination)
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
try createFile(at: filepath, contents: "hello")
|
||||
}
|
||||
|
||||
let failures = events.compactMap(\.watchFailure)
|
||||
#expect(failures.count == 1, "Expected exactly the locked directory to be reported, got: \(events)")
|
||||
#expect(failures.first?.path == FilePath(locked))
|
||||
#expect(failures.first?.error == .listDirectoryFailed(path: locked, errno: EACCES))
|
||||
let sibling = events.compactMap(\.fileSystemEvent).first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == filepath }
|
||||
#expect(sibling != nil, "Expected CREATE inside the readable sibling, got: \(events)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Events are transformed as they are consumed, so a directory that is
|
||||
/// created and removed before consumption starts is gone when the
|
||||
/// library tries to watch it.
|
||||
@Test func doesNotReportADirectoryThatVanishedBeforeItCouldBeWatched() async throws {
|
||||
try await withTempDir { dir in
|
||||
let vanished = "\(dir)/Vanished"
|
||||
let watcher = try Inotify()
|
||||
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: [.create])
|
||||
try FileManager.default.createDirectory(atPath: vanished, withIntermediateDirectories: false)
|
||||
try FileManager.default.removeItem(atPath: vanished)
|
||||
|
||||
let events = await collectEvents(of: watcher, for: .milliseconds(500))
|
||||
|
||||
#expect(events.compactMap(\.watchFailure).isEmpty, "Did not expect a watch failure for a vanished directory, got: \(events)")
|
||||
let created = events.compactMap(\.fileSystemEvent).first { $0.mask.contains(.create) && $0.path.string == vanished }
|
||||
#expect(created != nil, "Expected CREATE for '\(vanished)', got: \(events)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Inotify
|
||||
import SystemPackage
|
||||
|
||||
enum RecursivKind {
|
||||
case nonrecursive
|
||||
@@ -48,13 +49,7 @@ func getInotifyEventsForTrigger(
|
||||
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: mask)
|
||||
}
|
||||
|
||||
let eventTask = Task {
|
||||
var events: [InotifyEvent] = []
|
||||
for await event in await watcher.events {
|
||||
events.append(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
let eventTask = Task { await collectEvents(of: watcher) }
|
||||
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await trigger(dir)
|
||||
@@ -64,9 +59,54 @@ func getInotifyEventsForTrigger(
|
||||
return await eventTask.value
|
||||
}
|
||||
|
||||
/// Everything `watcher` delivers until the current task is cancelled.
|
||||
func collectEvents(of watcher: Inotify) async -> [InotifyEvent] {
|
||||
var events: [InotifyEvent] = []
|
||||
for await event in await watcher.events {
|
||||
events.append(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// Everything `watcher` delivers up to and including the first event that
|
||||
/// satisfies `predicate`, or until `timeout` passes.
|
||||
func collectEvents(
|
||||
of watcher: Inotify,
|
||||
until predicate: @escaping @Sendable (InotifyEvent) -> Bool,
|
||||
timeout: Duration
|
||||
) async -> [InotifyEvent] {
|
||||
let eventTask = Task { () -> [InotifyEvent] in
|
||||
var events: [InotifyEvent] = []
|
||||
for await event in await watcher.events {
|
||||
events.append(event)
|
||||
if predicate(event) { break }
|
||||
}
|
||||
return events
|
||||
}
|
||||
let timeoutTask = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
eventTask.cancel()
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
return await eventTask.value
|
||||
}
|
||||
|
||||
/// Everything `watcher` delivers within `duration`.
|
||||
func collectEvents(of watcher: Inotify, for duration: Duration) async -> [InotifyEvent] {
|
||||
let eventTask = Task { await collectEvents(of: watcher) }
|
||||
try? await Task.sleep(for: duration)
|
||||
eventTask.cancel()
|
||||
return await eventTask.value
|
||||
}
|
||||
|
||||
extension InotifyEvent {
|
||||
var fileSystemEvent: FileSystemEvent? {
|
||||
if case .fileSystem(let event) = self { return event }
|
||||
return nil
|
||||
}
|
||||
|
||||
var watchFailure: (path: FilePath, error: InotifyError)? {
|
||||
if case .watchFailed(let path, let error) = self { return (path, error) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user