From 21f096aede6c2ba4ad1f4e3142262d43f2b78c6d Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Sat, 19 Sep 2026 00:43:37 +0200 Subject: [PATCH] Remove the watches a failed tree call had already added A recursive watch that hit the watch limit or an unreadable directory threw after adding watches for part of the tree, which stayed in the instance and kept counting against the user's limit. The call now leaves the instance as it found it. The test needs a second instance for the check, because a repeated watch on the same instance only updates the existing one. --- .../Inotify.docc/WatchingDirectoryTrees.md | 2 +- Sources/Inotify/Inotify.swift | 20 ++++++++++++--- .../InotifyLimitTests.swift | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md index 40b5451..360f428 100644 --- a/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md +++ b/Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md @@ -18,7 +18,7 @@ let descriptors = try await inotify.addRecursiveWatch( ) ``` -The returned array contains one watch descriptor per directory. Subdirectories created **after** this call are not covered. +The returned array contains one watch descriptor per directory. Subdirectories created **after** this call are not covered. When one of the directories cannot be watched, for instance because the user's watch limit is reached, the call throws and removes the watches it had added, so the instance is left as it was. ### Automatic Subtree Watching diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 5f17c93..2947097 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -84,13 +84,19 @@ public actor Inotify { return wd } + /// Watches `path` and every directory below it, or throws and leaves no + /// watch behind when one of them cannot be watched. @discardableResult public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] { let directoryPaths = try await DirectoryResolver.resolve([path], excluding: self.exclusions) var result: [CInt] = [] - for path in directoryPaths { - let wd = try self.addWatch(path: path.string, mask: mask) - result.append(wd) + do { + for path in directoryPaths { + result.append(try self.addWatch(path: path.string, mask: mask)) + } + } catch { + self.dropWatches(result) + throw error } return result } @@ -144,7 +150,13 @@ public actor Inotify { /// are removed instead. private func removeWatchesInCaseADirectoryLeftTheTree(_ event: FileSystemEvent) { guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return } - for wd in self.watches.descriptors(under: event.path.string) { + self.dropWatches(self.watches.descriptors(under: event.path.string)) + } + + /// Removes watches whose failure does not matter, because their item is + /// gone or the watches are given up anyway. + private func dropWatches(_ wds: [CInt]) { + for wd in wds { inotify_rm_watch(self.fd, wd) self.watches.remove(forId: wd) } diff --git a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift index c490cea..61d0f49 100644 --- a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift +++ b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift @@ -18,6 +18,31 @@ struct InotifyLimitTests { } } + /// The limit counts every watch of the user, also those of other + /// processes, so it leaves room for the one watch of the second instance. + @Test func releasesTheWatchesOfATreeItCouldNotWatchCompletely() async throws { + try await withTempDir { dir in + try await withInotifyWatchLimit(of: 100, for: [.userWatches]) { + try createSubdirectorytree(at: dir, foldersPerLevel: 4, levels: 4) + let filepath = "\(dir)/new-file.txt" + let failedWatcher = try Inotify() + await #expect(throws: InotifyError.self) { + try await failedWatcher.addRecursiveWatch(forDirectory: dir, mask: .create) + } + + let events = try await getEventsForTrigger(in: dir, mask: .create) { _ in + try createFile(at: filepath, contents: "hello") + } + // Deallocating the failed instance would free its watches too, so it + // must live until the second instance has added its watch. + withExtendedLifetime(failedWatcher) {} + + let createEvent = events.first { $0.path.string == filepath } + #expect(createEvent != nil, "Expected a second instance to watch '\(dir)' after the failed one released its watches, got: \(events)") + } + } + } + @Test func watchesMassivSubtreesIfAllowed() async throws { try await withTempDir { dir in try await withInotifyWatchLimit(of: 1000) {