Report the directories a growing tree could not watch
Docs / docs (push) Canceled after 0s
Docs / deploy (push) Canceled after 0s

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:
T. R. Bernstein
2026-09-19 00:56:31 +02:00
parent 3215d2eb5a
commit c037302c01
11 changed files with 290 additions and 26 deletions
@@ -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]) {