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.
This commit is contained in:
T. R. Bernstein
2026-03-12 14:52:33 +01:00
parent ffac6d17a5
commit 76f91f67a6
5 changed files with 123 additions and 2 deletions

View File

@@ -36,7 +36,10 @@ let package = Package(
),
.testTarget(
name: "InotifyIntegrationTests",
dependencies: ["Inotify"],
dependencies: [
"Inotify",
.product(name: "SystemPackage", package: "swift-system")
],
),
.executableTarget(
name: "TaskCLI",

View File

@@ -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("")

View File

@@ -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)")
}
}
}
}

View File

@@ -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..<levels).reversed() {
if carry {
indices[i] += 1
if indices[i] > foldersPerLevel {
indices[i] = 1
} else {
carry = false
}
}
}
if carry { done = true }
return path
}
}

View File

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