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" : [ "pins" : [
{ {
"identity" : "noora", "identity" : "noora",
@@ -28,13 +28,22 @@
"version" : "4.2.1" "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", "identity" : "swift-argument-parser",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/apple/swift-argument-parser",
"state" : { "state" : {
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.1" "version" : "1.7.0"
} }
}, },
{ {
@@ -73,15 +82,6 @@
"version" : "2.95.0" "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", "identity" : "swift-system",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+2 -4
View File
@@ -11,11 +11,10 @@ let package = Package(
) )
], ],
dependencies: [ 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-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"), .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/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") .package(url: "https://github.com/tuist/Noora", from: "0.55.1")
], ],
targets: [ targets: [
@@ -39,10 +38,9 @@ let package = Package(
.executableTarget( .executableTarget(
name: "InotifyTaskCLI", name: "InotifyTaskCLI",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"), .product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Script", package: "Shwift"),
.product(name: "Noora", package: "Noora") .product(name: "Noora", package: "Noora")
], ],
path: "Sources/TaskCLI" path: "Sources/TaskCLI"
+1 -7
View File
@@ -64,7 +64,7 @@ Subdirectories created after the call are **not** watched.
### Automatic Subtree Watching ### 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 ```swift
try await inotify.addWatchWithAutomaticSubtreeWatching( 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. 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 ## 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: 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`. 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 ## Removing a Watch
Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later: 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 { for path in paths {
let path = FilePath(path) let path = FilePath(path)
resolved.append(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 return resolved
} }
/// The direct children of `directory`, without the excluded names. private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
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 {
let directoryHandle = try await fileManager.openDirectory(atPath: path) let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() { for try await childContent in directoryHandle.listContents() {
guard childContent.type == .directory else { continue } 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 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() 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. Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
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.
### Excluding Directories ### Excluding Directories
+14 -89
View File
@@ -1,36 +1,22 @@
import Dispatch import Dispatch
import CInotify import CInotify
import SystemPackage
public actor Inotify { public actor Inotify {
private let fd: CInt private let fd: CInt
private var excludedItemNames: Set<String> = [] private var excludedItemNames: Set<String> = []
private var watches = InotifyWatchManager() private var watches = InotifyWatchManager()
private nonisolated(unsafe) let eventReader: any DispatchSourceRead private var eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent> private var eventStream: AsyncStream<RawInotifyEvent>
private nonisolated let continuation: AsyncStream<RawInotifyEvent>.Continuation public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
self.eventStream.compactMap(self.transform(_:)) self.eventStream.compactMap(self.transform(_:))
} }
/// Creates an inotify instance. public init() throws {
///
/// 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 {
self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC)) self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC))
guard self.fd >= 0 else { guard self.fd >= 0 else {
throw InotifyError.initFailed(errno: cinotify_get_errno()) throw InotifyError.initFailed(errno: cinotify_get_errno())
} }
(self.eventReader, self.eventStream, self.continuation) = Self.createEventReader( (self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd)
forFileDescriptor: fd,
bufferingPolicy: bufferingPolicy
)
} }
public func isExcluded(_ name: String) -> Bool { public func isExcluded(_ name: String) -> Bool {
@@ -87,92 +73,32 @@ public actor Inotify {
} }
deinit { deinit {
// The file descriptor is closed by the reader's cancel handler once cinotify_deinit(self.fd)
// 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()
} }
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { 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 let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil } guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
let event = InotifyEvent.init(from: rawEvent, inDirectory: path) let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
self.forgetWatchInCaseTheKernelRemovedIt(event)
self.removeWatchesInCaseADirectoryLeftTheTree(event)
await self.addWatchInCaseOfAutomaticSubtreeWatching(event) await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
return event return InotifyEvent.init(from: rawEvent, inDirectory: path)
}
/// 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)
}
} }
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async { private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
guard !event.synthesized, guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
watches.isAutomaticSubtreeWatching(event.watchDescriptor), event.mask.contains(.create),
event.mask.contains(.isDir), event.mask.contains(.isDir) else {
let kind = Self.subtreeTrigger(in: event.mask) else {
return return
} }
guard let mask = self.watches.mask(forId: event.watchDescriptor) 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 } let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask)
await self.synthesizeEvents(forContentOfWatches: wds, kind: kind, cookie: event.cookie)
} }
private static func subtreeTrigger(in mask: InotifyEventMask) -> InotifyEventMask? { private static func createEventReader(forFileDescriptor fd: CInt) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
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) {
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream( let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
of: RawInotifyEvent.self, of: RawInotifyEvent.self,
bufferingPolicy: bufferingPolicy bufferingPolicy: .bufferingNewest(512)
) )
let reader = DispatchSource.makeReadSource( let reader = DispatchSource.makeReadSource(
@@ -186,11 +112,10 @@ public actor Inotify {
} }
} }
reader.setCancelHandler { reader.setCancelHandler {
cinotify_deinit(fd)
continuation.finish() continuation.finish()
} }
reader.activate() reader.activate()
return (reader, stream, continuation) return (reader, stream)
} }
} }
+1 -12
View File
@@ -1,20 +1,10 @@
import SystemPackage 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 struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let watchDescriptor: Int32 public let watchDescriptor: Int32
public let mask: InotifyEventMask public let mask: InotifyEventMask
public let cookie: UInt32 public let cookie: UInt32
public let path: FilePath 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 { public var description: String {
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""] var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
@@ -30,8 +20,7 @@ extension InotifyEvent {
watchDescriptor: rawEvent.watchDescriptor, watchDescriptor: rawEvent.watchDescriptor,
mask: rawEvent.mask, mask: rawEvent.mask,
cookie: rawEvent.cookie, cookie: rawEvent.cookie,
path: dirPath.appending(rawEvent.name), path: dirPath.appending(rawEvent.name)
synthesized: rawEvent.synthesized
) )
} }
} }
+1 -2
View File
@@ -31,8 +31,7 @@ struct InotifyEventParser {
watchDescriptor: rawEvent.wd, watchDescriptor: rawEvent.wd,
mask: InotifyEventMask(rawValue: rawEvent.mask), mask: InotifyEventMask(rawValue: rawEvent.mask),
cookie: rawEvent.cookie, cookie: rawEvent.cookie,
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len), name: Self.extractName(from: eventPointer, nameLength: rawEvent.len)
synthesized: false
)) ))
offset += Self.eventSize(nameLength: rawEvent.len) offset += Self.eventSize(nameLength: rawEvent.len)
@@ -36,14 +36,6 @@ struct InotifyWatchManager {
return self.watchPaths[watchDescriptor] 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? { func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
return self.watchMasks[watchDescriptor] return self.watchMasks[watchDescriptor]
} }
-3
View File
@@ -3,9 +3,6 @@ public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
public let mask: InotifyEventMask public let mask: InotifyEventMask
public let cookie: UInt32 public let cookie: UInt32
public let name: String 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 { public var description: String {
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""] var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
+1 -1
View File
@@ -1,4 +1,4 @@
import ArgumentParser import Script
@main @main
struct Command: AsyncParsableCommand { 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 Foundation
import Logging import Logging
import Script
import Noora import Noora
import Subprocess
struct GenerateDocumentationCommand: AsyncParsableCommand { struct GenerateDocumentationCommand: Script {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "generate-documentation", commandName: "generate-documentation",
abstract: "Generate DocC documentation of all targets inside a Linux container.", 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 logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
let fileManager = FileManager.default let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
let docker = try await executable(named: "docker")
let targets = try await Self.targets(for: projectDirectory) let targets = try await Self.targets(for: projectDirectory)
@@ -42,21 +42,16 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let script = Self.makeRunScript(for: targets) let script = Self.makeRunScript(for: targets)
logger.debug("Container script", metadata: ["script": "\(script)"]) logger.debug("Container script", metadata: ["script": "\(script)"])
let dockerRunResult = try await Subprocess.run( do {
.name("docker"), try await docker(
arguments: [
"run", "--rm", "run", "--rm",
"-v", "\(tempDirectory.path):/code", "-v", "\(tempDirectory.path):/code",
"-v", "swift-inotify-build-cache:/code/.build", "--platform", "linux/arm64",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"-w", "/code", "-w", "/code",
"swift:latest", "swift:latest",
"/bin/bash", "-c", script "/bin/bash", "-c", script,
],
output: .currentStandardOutput,
error: .currentStandardError
) )
if !dockerRunResult.terminationStatus.isSuccess { } catch {
noora.error("Documentation generation failed.") noora.error("Documentation generation failed.")
return return
} }
@@ -108,12 +103,10 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
} }
private static func packageTargets() async throws -> [(name: String, path: String)] { private static func packageTargets() async throws -> [(name: String, path: String)] {
let packageDescriptionResult = try await Subprocess.run( let swift = try await executable(named: "swift")
.name("swift"), let packageDescriptionOutput = try await outputOf {
arguments: ["package", "describe", "--type", "json"], try await swift("package", "describe", "--type", "json")
output: .data(limit: 10_000), }
error: .currentStandardError
)
struct PackageDescription: Codable { struct PackageDescription: Codable {
let targets: [Target] let targets: [Target]
@@ -123,11 +116,8 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let path: String let path: String
} }
if !packageDescriptionResult.terminationStatus.isSuccess { let data = Data(packageDescriptionOutput.utf8)
throw GenerateDocumentationError.unableToReadPackageDescription let package = try JSONDecoder().decode(PackageDescription.self, from: data)
}
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
return package.targets.map { ($0.name, $0.path) } return package.targets.map { ($0.name, $0.path) }
} }
@@ -172,16 +162,13 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
// MARK: - Dependency Injection // MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws { private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let swiftRunResult = try await Subprocess.run( let swift = try await executable(named: "swift")
.name("swift"), do {
arguments: [ try await swift(
"package", "--package-path", directory.path(percentEncoded: false), "package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL "add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
],
output: .currentStandardOutput,
error: .currentStandardError
) )
if !swiftRunResult.terminationStatus.isSuccess { } catch {
throw GenerateDocumentationError.dependencyInjectionFailed throw GenerateDocumentationError.dependencyInjectionFailed
} }
@@ -191,14 +178,11 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
enum GenerateDocumentationError: Error, CustomStringConvertible { enum GenerateDocumentationError: Error, CustomStringConvertible {
case dependencyInjectionFailed case dependencyInjectionFailed
case unableToReadPackageDescription
var description: String { var description: String {
switch self { switch self {
case .dependencyInjectionFailed: case .dependencyInjectionFailed:
"Failed to add swift-docc-plugin dependency to Package.swift." "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 import Logging
struct GlobalOptions: ParsableArguments { struct GlobalOptions: ParsableArguments {
+8 -14
View File
@@ -1,9 +1,8 @@
import ArgumentParser
import Foundation import Foundation
import Script
import Noora import Noora
import Subprocess
struct TestCommand: AsyncParsableCommand { struct TestCommand: Script {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "test", commandName: "test",
abstract: "Run swift test in a linux container.", abstract: "Run swift test in a linux container.",
@@ -18,26 +17,21 @@ struct TestCommand: AsyncParsableCommand {
let noora = Noora() let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test") let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test")
let currentDirectory = FileManager.default.currentDirectoryPath let currentDirectory = FileManager.default.currentDirectoryPath
let docker = Executable(path: "/opt/homebrew/bin/docker")
noora.info("Running tests on Linux.") noora.info("Running tests on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"]) logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
let dockerRunResult = try await Subprocess.run( do {
.name("docker"), try await docker(
arguments: [
"run", "run",
"-v", "\(currentDirectory):/code", "-v", "\(currentDirectory):/code",
"-v", "swift-inotify-build-cache:/code/.build",
"--security-opt", "systempaths=unconfined", "--security-opt", "systempaths=unconfined",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(), "--platform", "linux/arm64",
"-w", "/code", "swift:latest", "-w", "/code", "swift:latest",
"/bin/bash", "-c", "swift test --skip InotifyLimitTests && swift test --skip-build --filter InotifyLimitTests" "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
],
output: .currentStandardOutput,
error: .currentStandardError
) )
if dockerRunResult.terminationStatus.isSuccess {
noora.success("All tests completed successfully.") noora.success("All tests completed successfully.")
} else { } catch {
noora.error("Not all tests completed successfully.") 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]) #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)") #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 import Foundation
enum InotifyLimit: String, CaseIterable { func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async throws {
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 {
let confPath = URL(filePath: "/proc/sys/fs/inotify") 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] = [:] 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 { for filename in filenames {
let filePath = confPath.appending(path: filename) let filePath = confPath.appending(path: filename)
let currentLimit = try String(contentsOf: filePath, encoding: .utf8) let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
@@ -31,4 +13,10 @@ func withInotifyWatchLimit(
} }
try await body() 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)
}
} }