From 76f91f67a6d37235103b808b34f254603115662b Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Thu, 12 Mar 2026 14:52:33 +0100 Subject: [PATCH] Add integration tests for inofity limits inotify exposes a /proc interface to limit kernel memory usage. If those limits are set too low, inotify cannot add all watches. The integration test verifies, that Inotify yields an error in that case. --- Package.swift | 5 +- Sources/TaskCLI/Test Command.swift | 2 +- .../InotifyLimitTests.swift | 43 +++++++++++++++ .../Utilities/createSubdirectorytree.swift | 53 +++++++++++++++++++ .../Utilities/withLowInotifyWatchLimit.swift | 22 ++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 Tests/InotifyIntegrationTests/InotifyLimitTests.swift create mode 100644 Tests/InotifyIntegrationTests/Utilities/createSubdirectorytree.swift create mode 100644 Tests/InotifyIntegrationTests/Utilities/withLowInotifyWatchLimit.swift diff --git a/Package.swift b/Package.swift index 4bf2e19..ecaf54c 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,10 @@ let package = Package( ), .testTarget( name: "InotifyIntegrationTests", - dependencies: ["Inotify"], + dependencies: [ + "Inotify", + .product(name: "SystemPackage", package: "swift-system") + ], ), .executableTarget( name: "TaskCLI", diff --git a/Sources/TaskCLI/Test Command.swift b/Sources/TaskCLI/Test Command.swift index eae5633..d06a23d 100644 --- a/Sources/TaskCLI/Test Command.swift +++ b/Sources/TaskCLI/Test Command.swift @@ -24,7 +24,7 @@ struct TestCommand: AsyncParsableCommand { logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"]) async let monitorResult = Subprocess.run( .name("docker"), - arguments: ["run", "-v", "\(currentDirectory):/code", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "swift", "test"], + arguments: ["run", "-v", "\(currentDirectory):/code", "--security-opt", "systempaths=unconfined", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"], preferredBufferSize: 10, ) { execution, standardInput, standardOutput, standardError in print("") diff --git a/Tests/InotifyIntegrationTests/InotifyLimitTests.swift b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift new file mode 100644 index 0000000..30c79d3 --- /dev/null +++ b/Tests/InotifyIntegrationTests/InotifyLimitTests.swift @@ -0,0 +1,43 @@ +import Testing +import Foundation +@testable import Inotify + +@Suite("Inotify Limits", .serialized) +struct InotifyLimitTests { + @Test func throwsIfInotifyUpperLimitReached() async throws { + try await withTempDir { dir in + try await withInotifyWatchLimit(of: 10) { + try createSubdirectorytree(at: dir, foldersPerLevel: 4, levels: 3) + try await Task.sleep(for: .milliseconds(100)) + + await #expect(throws: InotifyError.self) { + let watcher = try Inotify() + try await watcher.addRecursiveWatch(forDirectory: dir, mask: .allEvents) + } + } + } + } + + @Test func watchesMassivSubtreesIfAllowed() async throws { + try await withTempDir { dir in + try await withInotifyWatchLimit(of: 1000) { + try createSubdirectorytree(at: dir, foldersPerLevel: 8, levels: 3) + let subDirectory = "\(dir)/Folder 8/Folder 8/Folder 8" + let filepath = "\(subDirectory)/new-file.txt" + try await Task.sleep(for: .milliseconds(100)) + + let events = try await getEventsForTrigger( + in: dir, + mask: [.create], + recursive: .recursive + ) { _ in + assert(FileManager.default.fileExists(atPath: subDirectory)) + try createFile(at: "\(filepath)", contents: "hello") + } + + let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath } + #expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)") + } + } + } +} diff --git a/Tests/InotifyIntegrationTests/Utilities/createSubdirectorytree.swift b/Tests/InotifyIntegrationTests/Utilities/createSubdirectorytree.swift new file mode 100644 index 0000000..6221fd0 --- /dev/null +++ b/Tests/InotifyIntegrationTests/Utilities/createSubdirectorytree.swift @@ -0,0 +1,53 @@ +import Foundation +import SystemPackage + +func createSubdirectorytree(at dir: String, foldersPerLevel: Int, levels: Int) throws { + let fileManager = FileManager.default + + for path in SubfolderTreeIterator(basePath: dir, foldersPerLevel: foldersPerLevel, levels: levels) { + try fileManager.createDirectory( + at: path, + withIntermediateDirectories: true, + attributes: nil + ) + } +} + +struct SubfolderTreeIterator: IteratorProtocol, Sequence { + let basePath: URL + let foldersPerLevel: Int + let levels: Int + private var indices: [Int] + private var done = false + + init(basePath: String, foldersPerLevel: Int, levels: Int) { + self.basePath = URL(filePath: basePath) + self.foldersPerLevel = foldersPerLevel + self.levels = levels + self.indices = Array(repeating: 1, count: levels) + } + + mutating func next() -> URL? { + guard !done else { return nil } + + let path = indices.reduce(basePath) { partialPath, index in + partialPath.appending(path: "Folder \(index)") + } + + // Advance indices (odometer-style, rightmost increments first) + var carry = true + for i in (0.. foldersPerLevel { + indices[i] = 1 + } else { + carry = false + } + } + } + if carry { done = true } + + return path + } +} diff --git a/Tests/InotifyIntegrationTests/Utilities/withLowInotifyWatchLimit.swift b/Tests/InotifyIntegrationTests/Utilities/withLowInotifyWatchLimit.swift new file mode 100644 index 0000000..d50966e --- /dev/null +++ b/Tests/InotifyIntegrationTests/Utilities/withLowInotifyWatchLimit.swift @@ -0,0 +1,22 @@ +import Foundation + +func withInotifyWatchLimit(of limit: Int, _ 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"] + var previousLimits: [String: String] = [:] + + for filename in filenames { + let filePath = confPath.appending(path: filename) + let currentLimit = try String(contentsOf: filePath, encoding: .utf8) + previousLimits[filename] = currentLimit + try "\(limit)".write(to: filePath, atomically: false, encoding: .utf8) + } + + 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) + } +}