1 Commits
Author SHA1 Message Date
T. R. Bernstein 541b9d68a0 Use Shwift library instead of Subprocess
Docs / deploy (push) Has been cancelled
Docs / docs (push) Has been cancelled
Shwift has a concise API, which makes writing shell code nice and easy.
This is an opinionated decision.
2026-03-20 21:44:05 +01:00
21 changed files with 80 additions and 400 deletions
+12 -12
View File
@@ -1,5 +1,5 @@
{
"originHash" : "d30dadbb08ce17a04cba957d25e81d1d76b8dc0a7bdc84a591c7af3b8eb74b85",
"originHash" : "0cb2e87817f52021ac25ffee6b27396f6d94e9fd604ca83db7f20a10e65fe6cf",
"pins" : [
{
"identity" : "noora",
@@ -28,13 +28,22 @@
"version" : "4.2.1"
}
},
{
"identity" : "shwift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/GeorgeLyon/Shwift",
"state" : {
"revision" : "d7be04898d094ddce6140cc6a2e9a83fc994b66d",
"version" : "3.1.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
"version" : "1.7.1"
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
@@ -73,15 +82,6 @@
"version" : "2.95.0"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "b3937ab85dd32f6e9435914599c1519074769c1a",
"version" : "1.0.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
+2 -4
View File
@@ -11,11 +11,10 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", "0.3.0"..<"2.0.0"),
.package(url: "https://github.com/GeorgeLyon/Shwift", from: "3.1.1"),
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
],
targets: [
@@ -39,10 +38,9 @@ let package = Package(
.executableTarget(
name: "InotifyTaskCLI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Script", package: "Shwift"),
.product(name: "Noora", package: "Noora")
],
path: "Sources/TaskCLI"
+1 -7
View File
@@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched.
### Automatic Subtree Watching
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` and `MOVED_TO` events with the `isDir` flag. Whenever a subdirectory appears, whether created or moved in, a watch is installed on it and on its subdirectories automatically:
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` events with the `isDir` flag. Whenever a new subdirectory appears, a watch is installed on it automatically:
```swift
try await inotify.addWatchWithAutomaticSubtreeWatching(
@@ -75,10 +75,6 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
This is the most convenient option when you need full coverage of a growing directory tree.
Items that already exist inside a directory that appears this way never produce kernel events. The library reports them as if they had just appeared, using the same kind of event (`CREATE` or `MOVED_TO`), with `synthesized` set to `true`. A synthesized event may duplicate a kernel event for the same item, so consumers that act on events should tolerate seeing an item twice.
When a watched directory is moved out of the tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path.
## Excluding Items
You can tell the `Inotify` actor to ignore certain file or directory names. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream:
@@ -122,8 +118,6 @@ Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`.
Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.unmount`.
When the kernel queue overflows, events are lost and a single event with `.queueOverflow` is delivered instead. It has no path and a watch descriptor of `-1`; rescan the watched directories if you must not miss changes.
## Removing a Watch
Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later:
+9 -19
View File
@@ -13,34 +13,24 @@ public struct DirectoryResolver {
for path in paths {
let path = FilePath(path)
resolved.append(path)
try await withSubdirectories(at: path, excluding: itemNames) { resolved.append($0) }
try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in
guard let basename = subdirectoryPath.lastComponent?.description else { return }
guard !itemNames.contains(basename) else { return }
resolved.append(subdirectoryPath)
}
}
return resolved
}
/// The direct children of `directory`, without the excluded names.
static func entries(of directory: FilePath, excluding itemNames: Set<String> = []) async throws -> [(name: String, isDirectory: Bool)] {
let directoryHandle = try await fileManager.openDirectory(atPath: directory)
var entries: [(name: String, isDirectory: Bool)] = []
for try await childContent in directoryHandle.listContents() {
guard let name = childContent.path.lastComponent?.string else { continue }
guard !itemNames.contains(name) else { continue }
entries.append((name: name, isDirectory: childContent.type == .directory))
}
try await directoryHandle.close()
return entries
}
/// Calls `body` for every subdirectory below `path`, depth first. Excluded
/// names are neither reported nor descended into.
private static func withSubdirectories(at path: FilePath, excluding itemNames: Set<String>, body: (FilePath) async throws -> Void) async throws {
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() {
guard childContent.type == .directory else { continue }
guard let name = childContent.path.lastComponent?.string, !itemNames.contains(name) else { continue }
try await body(childContent.path)
try await withSubdirectories(at: childContent.path, excluding: itemNames, body: body)
if recursive {
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
}
}
try await directoryHandle.close()
}
@@ -31,9 +31,7 @@ let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching(
)
```
Internally this listens for `CREATE` and `MOVED_TO` events carrying the ``InotifyEventMask/isDir`` flag and installs new watches with the same mask on the subdirectory and its subtree whenever one appears. Items that already exist inside such a subdirectory are reported with ``InotifyEvent/synthesized`` set to `true`, since the kernel never produces events for them; a synthesized event may duplicate a kernel event for the same item.
When a directory is moved out of the watched tree, the watches on it and on its subdirectories are removed, so no events are reported under the stale path.
Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
### Excluding Directories
+14 -89
View File
@@ -1,36 +1,22 @@
import Dispatch
import CInotify
import SystemPackage
public actor Inotify {
private let fd: CInt
private var excludedItemNames: Set<String> = []
private var watches = InotifyWatchManager()
private nonisolated(unsafe) let eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
private var eventReader: any DispatchSourceRead
private var eventStream: AsyncStream<RawInotifyEvent>
public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
self.eventStream.compactMap(self.transform(_:))
}
/// Creates an inotify instance.
///
/// Events are read from the kernel as soon as they arrive and buffered
/// until they are consumed from ``events``.
///
/// - Parameter bufferingPolicy: How events are kept while no consumer is
/// reading ``events``. The default `.unbounded` keeps every event, so a
/// burst of changes is never lost; a bounded policy trades memory for
/// dropped events.
public init(bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy = .unbounded) throws {
public init() throws {
self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC))
guard self.fd >= 0 else {
throw InotifyError.initFailed(errno: cinotify_get_errno())
}
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader(
forFileDescriptor: fd,
bufferingPolicy: bufferingPolicy
)
(self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd)
}
public func isExcluded(_ name: String) -> Bool {
@@ -87,92 +73,32 @@ public actor Inotify {
}
deinit {
// The file descriptor is closed by the reader's cancel handler once
// libdispatch has unregistered it. Closing it here would leave a
// registration behind that a later instance reusing the descriptor
// number could inherit, silently losing its events.
self.eventReader.cancel()
cinotify_deinit(self.fd)
}
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
if rawEvent.mask.contains(.queueOverflow) {
return InotifyEvent(from: rawEvent, inDirectory: "")
}
guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
self.forgetWatchInCaseTheKernelRemovedIt(event)
self.removeWatchesInCaseADirectoryLeftTheTree(event)
await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
return event
}
/// The kernel reports `IN_IGNORED` once a watch is gone, whether it was
/// removed explicitly or because its item was deleted or unmounted.
/// Forgetting it keeps a reused descriptor number from mapping to a
/// stale path.
private func forgetWatchInCaseTheKernelRemovedIt(_ event: InotifyEvent) {
guard event.mask.contains(.ignored) else { return }
self.watches.remove(forId: event.watchDescriptor)
}
/// A directory moved out of a watched tree keeps its kernel watches,
/// which would then report events under the old path. Those watches
/// are removed instead.
private func removeWatchesInCaseADirectoryLeftTheTree(_ event: InotifyEvent) {
guard event.mask.contains(.movedFrom), event.mask.contains(.isDir) else { return }
for wd in self.watches.descriptors(under: event.path.string) {
inotify_rm_watch(self.fd, wd)
self.watches.remove(forId: wd)
}
return InotifyEvent.init(from: rawEvent, inDirectory: path)
}
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
guard !event.synthesized,
watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.isDir),
let kind = Self.subtreeTrigger(in: event.mask) else {
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
event.mask.contains(.create),
event.mask.contains(.isDir) else {
return
}
guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return }
guard let wds = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask) else { return }
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie)
let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask)
}
private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? {
if mask.contains(.create) { return .create }
if mask.contains(.movedTo) { return .movedTo }
return nil
}
/// Items that already exist when a directory becomes watched never
/// produce kernel events, so they are reported as if they had just
/// appeared, marked as synthesized.
private func synthesizeEvents(forContentOfWatches wds: [CInt], kind: InotifyEventMask, cookie: UInt32) async {
for wd in wds {
guard let directory = self.watches.path(forId: wd) else { continue }
guard let entries = try? await DirectoryResolver.entries(of: FilePath(directory), excluding: self.excludedItemNames) else { continue }
for entry in entries {
let mask: InotifyEventMask = entry.isDirectory ? [kind, .isDir] : kind
self.continuation.yield(RawInotifyEvent(
watchDescriptor: wd,
mask: mask,
cookie: cookie,
name: entry.name,
synthesized: true
))
}
}
}
private static func createEventReader(
forFileDescriptor fd: CInt,
bufferingPolicy: AsyncStream<RawInotifyEvent>.Continuation.BufferingPolicy
) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>, AsyncStream<RawInotifyEvent>.Continuation) {
private static func createEventReader(forFileDescriptor fd: CInt) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
of: RawInotifyEvent.self,
bufferingPolicy: bufferingPolicy
bufferingPolicy: .bufferingNewest(512)
)
let reader = DispatchSource.makeReadSource(
@@ -186,11 +112,10 @@ public actor Inotify {
}
}
reader.setCancelHandler {
cinotify_deinit(fd)
continuation.finish()
}
reader.activate()
return (reader, stream, continuation)
return (reader, stream)
}
}
+1 -12
View File
@@ -1,20 +1,10 @@
import SystemPackage
/// A filesystem event delivered by an ``Inotify`` instance.
///
/// When the kernel's event queue overflows, it drops events and reports a
/// single event whose ``mask`` contains ``InotifyEventMask/queueOverflow``.
/// Such an event belongs to no watch: its ``watchDescriptor`` is `-1` and
/// its ``path`` is empty. Consumers that must not miss changes should
/// rescan the watched trees when they receive one.
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let watchDescriptor: Int32
public let mask: InotifyEventMask
public let cookie: UInt32
public let path: FilePath
/// Whether the event was produced by the library for an item that already
/// existed when its directory became watched, rather than by the kernel.
public let synthesized: Bool
public var description: String {
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
@@ -30,8 +20,7 @@ extension InotifyEvent {
watchDescriptor: rawEvent.watchDescriptor,
mask: rawEvent.mask,
cookie: rawEvent.cookie,
path: dirPath.appending(rawEvent.name),
synthesized: rawEvent.synthesized
path: dirPath.appending(rawEvent.name)
)
}
}
+1 -2
View File
@@ -31,8 +31,7 @@ struct InotifyEventParser {
watchDescriptor: rawEvent.wd,
mask: InotifyEventMask(rawValue: rawEvent.mask),
cookie: rawEvent.cookie,
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len),
synthesized: false
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len)
))
offset += Self.eventSize(nameLength: rawEvent.len)
@@ -36,14 +36,6 @@ struct InotifyWatchManager {
return self.watchPaths[watchDescriptor]
}
/// The descriptors of the watch on `path` itself and of every watch below it.
func descriptors(under path: String) -> [CInt] {
let prefix = path.hasSuffix("/") ? path : path + "/"
return self.watchPaths
.filter { $0.value == path || $0.value.hasPrefix(prefix) }
.map(\.key)
}
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
return self.watchMasks[watchDescriptor]
}
-3
View File
@@ -3,9 +3,6 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let mask: InotifyEventMask
public let cookie: UInt32
public let name: String
/// Whether the event was produced by the library for an item that already
/// existed when its directory became watched, rather than by the kernel.
public let synthesized: Bool
public var description: String {
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
+1 -1
View File
@@ -1,4 +1,4 @@
import ArgumentParser
import Script
@main
struct Command: AsyncParsableCommand {
-9
View File
@@ -1,9 +0,0 @@
struct Docker {
static func getLinuxPlatformStringWithHostArchitecture() -> String {
#if arch(x86_64)
return "linux/amd64"
#else
return "linux/arm64"
#endif
}
}
@@ -1,10 +1,9 @@
import ArgumentParser
import Foundation
import Logging
import Script
import Noora
import Subprocess
struct GenerateDocumentationCommand: AsyncParsableCommand {
struct GenerateDocumentationCommand: Script {
static let configuration = CommandConfiguration(
commandName: "generate-documentation",
abstract: "Generate DocC documentation of all targets inside a Linux container.",
@@ -24,6 +23,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
let docker = try await executable(named: "docker")
let targets = try await Self.targets(for: projectDirectory)
@@ -42,21 +42,16 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let script = Self.makeRunScript(for: targets)
logger.debug("Container script", metadata: ["script": "\(script)"])
let dockerRunResult = try await Subprocess.run(
.name("docker"),
arguments: [
do {
try await docker(
"run", "--rm",
"-v", "\(tempDirectory.path):/code",
"-v", "swift-inotify-build-cache:/code/.build",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"--platform", "linux/arm64",
"-w", "/code",
"swift:latest",
"/bin/bash", "-c", script
],
output: .currentStandardOutput,
error: .currentStandardError
"/bin/bash", "-c", script,
)
if !dockerRunResult.terminationStatus.isSuccess {
} catch {
noora.error("Documentation generation failed.")
return
}
@@ -108,12 +103,10 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
}
private static func packageTargets() async throws -> [(name: String, path: String)] {
let packageDescriptionResult = try await Subprocess.run(
.name("swift"),
arguments: ["package", "describe", "--type", "json"],
output: .data(limit: 10_000),
error: .currentStandardError
)
let swift = try await executable(named: "swift")
let packageDescriptionOutput = try await outputOf {
try await swift("package", "describe", "--type", "json")
}
struct PackageDescription: Codable {
let targets: [Target]
@@ -123,11 +116,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let path: String
}
if !packageDescriptionResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.unableToReadPackageDescription
}
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
let data = Data(packageDescriptionOutput.utf8)
let package = try JSONDecoder().decode(PackageDescription.self, from: data)
return package.targets.map { ($0.name, $0.path) }
}
@@ -172,16 +162,13 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
// MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let swiftRunResult = try await Subprocess.run(
.name("swift"),
arguments: [
let swift = try await executable(named: "swift")
do {
try await swift(
"package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
],
output: .currentStandardOutput,
error: .currentStandardError
)
if !swiftRunResult.terminationStatus.isSuccess {
} catch {
throw GenerateDocumentationError.dependencyInjectionFailed
}
@@ -191,14 +178,11 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
enum GenerateDocumentationError: Error, CustomStringConvertible {
case dependencyInjectionFailed
case unableToReadPackageDescription
var description: String {
switch self {
case .dependencyInjectionFailed:
"Failed to add swift-docc-plugin dependency to Package.swift."
case .unableToReadPackageDescription:
"Failed to read the package description."
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import ArgumentParser
import Script
import Logging
struct GlobalOptions: ParsableArguments {
+8 -14
View File
@@ -1,9 +1,8 @@
import ArgumentParser
import Foundation
import Script
import Noora
import Subprocess
struct TestCommand: AsyncParsableCommand {
struct TestCommand: Script {
static let configuration = CommandConfiguration(
commandName: "test",
abstract: "Run swift test in a linux container.",
@@ -18,26 +17,21 @@ struct TestCommand: AsyncParsableCommand {
let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test")
let currentDirectory = FileManager.default.currentDirectoryPath
let docker = Executable(path: "/opt/homebrew/bin/docker")
noora.info("Running tests on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
let dockerRunResult = try await Subprocess.run(
.name("docker"),
arguments: [
do {
try await docker(
"run",
"-v", "\(currentDirectory):/code",
"-v", "swift-inotify-build-cache:/code/.build",
"--security-opt", "systempaths=unconfined",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"--platform", "linux/arm64",
"-w", "/code", "swift:latest",
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests"
],
output: .currentStandardOutput,
error: .currentStandardError
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
)
if dockerRunResult.terminationStatus.isSuccess {
noora.success("All tests completed successfully.")
} else {
} catch {
noora.error("Not all tests completed successfully.")
}
}
@@ -1,32 +0,0 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Event Buffering")
struct BufferingTests {
@Test func deliversEveryEventOfABurstToALateConsumer() async throws {
try await withTempDir { dir in
let fileCount = 1000
let watcher = try Inotify()
try await watcher.addWatch(path: dir, mask: .create)
for index in 0..<fileCount {
try createFile(at: "\(dir)/file-\(index).txt")
}
try await Task.sleep(for: .milliseconds(500))
let eventTask = Task {
var events: [InotifyEvent] = []
for await event in await watcher.events {
events.append(event)
}
return events
}
try await Task.sleep(for: .seconds(1))
eventTask.cancel()
let events = await eventTask.value
#expect(events.count == fileCount, "Expected \(fileCount) CREATE events, got \(events.count)")
}
}
}
@@ -14,14 +14,4 @@ struct DirectoryResolverTests {
#expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory])
}
}
@Test func doesNotDescendIntoExcludedDirectories() async throws {
try await withTempDir { dir in
let excludedSubdirectory = "\(dir)/Excluded/Inside"
try FileManager.default.createDirectory(atPath: excludedSubdirectory, withIntermediateDirectories: true)
let directories = try await DirectoryResolver.resolve(dir, excluding: ["Excluded"])
#expect(directories.map { $0.description } == [dir])
}
}
}
@@ -40,35 +40,4 @@ struct InotifyLimitTests {
}
}
}
@Test func reportsQueueOverflowInsteadOfDroppingIt() async throws {
try await withTempDir { dir in
try await withInotifyWatchLimit(of: 1, for: [.queuedEvents]) {
let watcher = try Inotify()
try await watcher.addWatch(path: dir, mask: .allEvents)
let overflowTask = Task { () -> (InotifyEvent?, Int) in
var received = 0
for await event in await watcher.events {
received += 1
if event.mask.contains(.queueOverflow) { return (event, received) }
}
return (nil, received)
}
let deadline = ContinuousClock.now + .seconds(5)
var index = 0
while !overflowTask.isCancelled, ContinuousClock.now < deadline {
try createFile(at: "\(dir)/burst-\(index).txt", contents: "hello")
index += 1
if index % 200 == 0 { await Task.yield() }
}
overflowTask.cancel()
let (overflow, received) = await overflowTask.value
#expect(overflow != nil, "Expected a queue overflow event after \(index) file creations and \(received) received events")
#expect(overflow?.watchDescriptor == -1)
#expect(overflow?.path == "")
}
}
}
}
@@ -1,24 +0,0 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Instance Lifecycle")
struct LifecycleTests {
@Test func aDeallocatedInstanceDoesNotStealEventsOfItsSuccessor() async throws {
try await withTempDir { dir in
let filename = "after-reuse.txt"
do {
let predecessor = try Inotify()
try await predecessor.addWatch(path: dir, mask: .create)
}
let events = try await getEventsForTrigger(
in: dir,
mask: .create,
) { try createFile(at: "\($0)/\(filename)") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
#expect(createEvent != nil, "Expected CREATE for '\(filename)', got: \(events)")
}
}
}
@@ -58,66 +58,4 @@ struct RecursiveEventTests {
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
}
}
@Test func stopsReportingForDirectoriesMovedOutOfTheWatchedTree() async throws {
try await withTempDir { dir in
let root = "\(dir)/Root"
let outside = "\(dir)/Outside"
let movedSource = "\(root)/Moved"
let movedDestination = "\(outside)/Moved"
let filename = "created-after-move.txt"
try FileManager.default.createDirectory(atPath: movedSource, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: outside, withIntermediateDirectories: true)
let events = try await getEventsForTrigger(
in: root,
mask: [.create, .movedFrom],
recursive: .withAutomaticSubtreeWatching
) { _ in
try FileManager.default.moveItem(atPath: movedSource, toPath: movedDestination)
try await Task.sleep(for: .milliseconds(400))
try createFile(at: "\(movedDestination)/\(filename)", contents: "hello")
}
let staleEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
#expect(staleEvent == nil, "Did not expect CREATE for '\(filename)' after its directory left the tree, got: \(events)")
}
}
@Test func watchesAndReportsContentOfDirectoriesMovedIntoTheTree() async throws {
try await withTempDir { dir in
let root = "\(dir)/Root"
let treeSource = "\(dir)/Outside/Tree"
let treeDestination = "\(root)/Tree"
try FileManager.default.createDirectory(atPath: "\(treeSource)/Sub", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
try createFile(at: "\(treeSource)/existing.txt", contents: "hello")
try createFile(at: "\(treeSource)/Sub/nested.txt", contents: "hello")
let events = try await getEventsForTrigger(
in: root,
mask: [.create, .movedTo],
recursive: .withAutomaticSubtreeWatching
) { _ in
try FileManager.default.moveItem(atPath: treeSource, toPath: treeDestination)
try await Task.sleep(for: .milliseconds(400))
try createFile(at: "\(treeDestination)/Sub/created-after-move.txt", contents: "hello")
}
let movedIn = events.first { $0.mask.contains(.movedTo) && $0.mask.contains(.isDir) && $0.path.string == treeDestination }
#expect(movedIn != nil, "Expected MOVED_TO for '\(treeDestination)', got: \(events)")
let existing = events.first { $0.synthesized && $0.mask.contains(.movedTo) && $0.path.string == "\(treeDestination)/existing.txt" }
#expect(existing != nil, "Expected a synthesized MOVED_TO for the existing file, got: \(events)")
let subdirectory = events.first { $0.synthesized && $0.mask.contains(.isDir) && $0.path.string == "\(treeDestination)/Sub" }
#expect(subdirectory != nil, "Expected a synthesized MOVED_TO for the existing subdirectory, got: \(events)")
let nested = events.first { $0.synthesized && $0.path.string == "\(treeDestination)/Sub/nested.txt" }
#expect(nested != nil, "Expected a synthesized MOVED_TO for the nested file, got: \(events)")
let createdAfterMove = events.first { !$0.synthesized && $0.mask.contains(.create) && $0.path.string == "\(treeDestination)/Sub/created-after-move.txt" }
#expect(createdAfterMove != nil, "Expected CREATE inside the moved-in subdirectory, got: \(events)")
}
}
}
@@ -1,28 +1,10 @@
import Foundation
enum InotifyLimit: String, CaseIterable {
case userWatches = "max_user_watches"
case userInstances = "max_user_instances"
case queuedEvents = "max_queued_events"
}
func withInotifyWatchLimit(
of limit: Int,
for limits: [InotifyLimit] = InotifyLimit.allCases,
_ body: () async throws -> Void
) async throws {
func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async throws {
let confPath = URL(filePath: "/proc/sys/fs/inotify")
let filenames = limits.map(\.rawValue)
let filenames = ["max_user_watches", "max_user_instances", "max_queued_events"]
var previousLimits: [String: String] = [:]
defer {
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)
}
}
for filename in filenames {
let filePath = confPath.appending(path: filename)
let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
@@ -31,4 +13,10 @@ func withInotifyWatchLimit(
}
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)
}
}