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) + } +}