diff --git a/Examples/info-provider/InfoProvider.swift b/Examples/info-provider/InfoProvider.swift new file mode 100644 index 000000000..273bca72c --- /dev/null +++ b/Examples/info-provider/InfoProvider.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Foundation + +@MainActor +final class Info { + let start = Date() + var signalCount = 0 +} + +let info = Info() + +@main +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +struct InfoProvider: AsyncParsableCommand, InfoProvidingParsableCommand { + func run() async throws { + _ = info + + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) || os(FreeBSD) || os(OpenBSD) + print("Press Ctrl+T to see process information.") + #elseif os(Linux) || os(Android) + print("Send SIGUSR1 to \(getpid()) to see process information.") + #elseif os(Windows) + print("Press Ctrl+Break to see process information.") + #endif + + let dot = [UInt8(ascii: ".")] + while true { + try await Task.sleep(nanoseconds: 1_000_000_000) + if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { + try? FileHandle.standardOutput.write(contentsOf: dot) + } else { + FileHandle.standardOutput.write(Data(dot)) + } + } + } + + @MainActor + static func provideInfo() { + let timeRunning = Date().timeIntervalSince(info.start) + print("Running for \(timeRunning) seconds.") + + info.signalCount += 1 + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) || os(FreeBSD) || os(OpenBSD) + print("SIGINFO received \(info.signalCount) time(s)!") + #elseif os(Linux) || os(Android) + print("SIGUSR1 received \(info.signalCount) time(s)!") + #elseif os(Windows) + print("Ctrl+Break received \(info.signalCount) time(s)!") + #endif + } +} diff --git a/Package.swift b/Package.swift index bc835353a..66c2a7ddb 100644 --- a/Package.swift +++ b/Package.swift @@ -83,6 +83,11 @@ var package = Package( dependencies: ["ArgumentParser"], path: "Examples/default-as-flag" ), + .executableTarget( + name: "info-provider", + dependencies: ["ArgumentParser"], + path: "Examples/info-provider" + ), // Tools .executableTarget( diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index b19e7a8d1..6d50c6ade 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -20,6 +20,7 @@ add_library(ArgumentParser "Parsable Types/CommandConfiguration.swift" "Parsable Types/EnumerableFlag.swift" "Parsable Types/ExpressibleByArgument.swift" + "Parsable Types/InfoProvidingCommand.swift" "Parsable Types/ParsableArguments.swift" "Parsable Types/ParsableCommand.swift" @@ -46,6 +47,7 @@ add_library(ArgumentParser Utilities/Foundation.swift Utilities/Platform.swift Utilities/SequenceExtensions.swift + Utilities/SIGINFOHandler.swift Utilities/StringExtensions.swift Utilities/SwiftExtensions.swift Utilities/Tree.swift diff --git a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift index eb8ab0585..2fce3d168 100644 --- a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift @@ -65,6 +65,16 @@ extension AsyncParsableCommand { public static func main(_ arguments: [String]?) async { do { var command = try await asyncParseAsRoot(arguments) + + var siginfoHandler: SIGINFOHandler? + if let command = command as? any InfoProvidingParsableCommand { + siginfoHandler = SIGINFOHandler(for: command) + } + siginfoHandler?.register() + defer { + siginfoHandler?.unregister() + } + if var asyncCommand = command as? AsyncParsableCommand { try await asyncCommand.run() } else { diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift new file mode 100644 index 000000000..e10f92666 --- /dev/null +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// A parsable command that can provide information back to the user while it +/// runs. +/// +/// The user may request information from a running process via a +/// platform-specific mechanism: +/// +/// - On Apple platforms, FreeBSD, and OpenBSD, by sending `SIGINFO` to the +/// process or by pressing Ctrl+T in the Terminal application. +/// - On Linux, by sending the `SIGUSR1` signal to the process. +/// - On Windows, by pressing Ctrl+Break in the Terminal application. +/// +/// If your root command conforms to this protocol, Swift Argument Parser +/// automatically sets up a signal handler to listen for `SIGINFO` (or the +/// platform-specific equivalent). +/// +/// On platforms that do not support providing information, conformance to this +/// protocol has no effect. +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +public protocol InfoProvidingParsableCommand: Sendable, ParsableCommand { + #if compiler(>=6.2) + /// Provide information about the state of the process and about the + /// currently-running command. + /// + /// The information you provide is program-specific. Programs will often + /// provide a status update such as the percentage or count of work completed, + /// information about the currently-running work item, etc. + /// + /// - Important: Swift Argument Parser calls this function asynchronously + /// while your program is running. If your command type is actor-isolated, + /// ensure your ``AsyncParsableCommand/run()`` implementation periodically + /// yields control by suspending, sleeping, or calling [`Task.yield()`](https://developer.apple.com/documentation/swift/task/yield()). + /// + /// If ``AsyncParsableCommand/run()`` never yields, the Swift runtime may + /// not be able to schedule calls to this function and it will appear to the + /// user as if it is not implemented. + nonisolated(nonsending) static func provideInfo() async + #else + static func provideInfo() async + #endif +} + +// MARK: - + +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +extension SIGINFOHandler { + convenience init(for command: T) where T: InfoProvidingParsableCommand { + self.init { + #if compiler(>=6.3) + if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) { + _ = Task.immediate { + await T.provideInfo() + } + return + } + #endif + + _ = Task { + await T.provideInfo() + } + } + } +} diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index 1f2cd2e50..e74120594 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -158,6 +158,18 @@ extension ParsableCommand { do { var command = try parseAsRoot(arguments) + + var siginfoHandler: SIGINFOHandler? + if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *), + let command = command as? any InfoProvidingParsableCommand + { + siginfoHandler = SIGINFOHandler(for: command) + } + siginfoHandler?.register() + defer { + siginfoHandler?.unregister() + } + try command.run() } catch { exit(withError: error) diff --git a/Sources/ArgumentParser/Utilities/SIGINFOHandler.swift b/Sources/ArgumentParser/Utilities/SIGINFOHandler.swift new file mode 100644 index 000000000..e24e01698 --- /dev/null +++ b/Sources/ArgumentParser/Utilities/SIGINFOHandler.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(Dispatch) +private import Dispatch +#endif + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#elseif canImport(WinSDK) +import WinSDK +#elseif canImport(WASILibc) +import WASILibc +#elseif canImport(Android) +import Android +#endif + +/// A class whose instances represent `SIGINFO` handlers configured in this +/// process. +/// +/// - Note: This class is responsible for handling `SIGINFO`, `SIGUSR1` (Linux), +/// and Ctrl+Break (Windows). Naming is hard. +final class SIGINFOHandler: Sendable { + /// The set of `SIGINFO` handlers configured in this process. + /// + /// Generally, this array will contain no more than `1` element, but it can be + /// larger if multiple commands are made to run in a single process. + private static let allSIGINFOHandlers = Mutex<[SIGINFOHandler]>([]) + + #if canImport(Dispatch) + /// The queue on which SIGINFO handler callbacks are invoked. + /// + /// Perhaps the natural queue for signal handling is the main queue, but we do + /// not control user code and we cannot ensure that it doesn't block the main + /// queue/thread/actor for long periods of time, which would prevent our + /// dispatch source from ever firing its event handler. + private static let siginfoQueue = DispatchQueue( + label: "SIGINFO monitoring queue", + qos: .userInitiated, + autoreleaseFrequency: .workItem) + + /// The dispatch source that listens for `SIGINFO` (or the platform-specific + /// equivalent). + /// + /// This declaration is annotated `nonisolated(unsafe)` because dispatch sources + /// do not conform to `Sendable` on non-Darwin targets. + private nonisolated(unsafe) static let siginfoSource: Any = { + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) || os(FreeBSD) || os(OpenBSD) + let source = DispatchSource.makeSignalSource( + signal: SIGINFO, queue: siginfoQueue) + #elseif os(Linux) || os(Android) + // On Linux, SIGINFO is not defined, so we'll use SIGUSR1 for this purpose. + let source = DispatchSource.makeSignalSource( + signal: SIGUSR1, queue: siginfoQueue) + #elseif os(Windows) + let source = DispatchSource.makeUserDataAddSource(queue: siginfoQueue) + SetConsoleCtrlHandler( + { ctrlType in + guard ctrlType == CTRL_BREAK_EVENT else { + // Let the system handle it normally. + return false + } + if let siginfoSource = siginfoSource as? any DispatchSourceUserDataAdd { + siginfoSource.add(data: 1) + } + return true + }, true) + #else + // This platform does not support SIGINFO or any equivalent. To keep this + // code relatively simple, we still create a no-op dispatch source. + let source = DispatchSource.makeUserDataAddSource(queue: siginfoQueue) + #endif + + source.setEventHandler { + // Invoke all registered handler objects. + let handlers = allSIGINFOHandlers.withLock { $0 } + for handler in handlers { + handler.handler() + } + } + source.activate() + return source + }() + #endif + + /// The handler function this instance represents. + private let handler: @Sendable () -> Void + + init(handlingWith handler: @escaping @Sendable () -> Void) { + self.handler = handler + } + + /// Register this handler and start using it to listen for signals. + func register() { + Self.allSIGINFOHandlers.withLock { all in + all.append(self) + } + + #if canImport(Dispatch) + // Ensure we're listening for signals after adding this handler. + _ = Self.siginfoSource + #endif + } + + /// Unregister this handler and stop using it to listen for signals. + func unregister() { + Self.allSIGINFOHandlers.withLock { all in + all.removeAll { $0 === self } + } + } +} diff --git a/Tests/ArgumentParserUnitTests/CMakeLists.txt b/Tests/ArgumentParserUnitTests/CMakeLists.txt index 6e2bfaf03..beff12c95 100644 --- a/Tests/ArgumentParserUnitTests/CMakeLists.txt +++ b/Tests/ArgumentParserUnitTests/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(UnitTests HelpGenerationTests+AtOption.swift HelpGenerationTests+GroupName.swift HelpGenerationTests+HelpBanner.swift + InfoProvidingTests.swift NameSpecificationTests.swift SerializedCompletionSuites.swift SplitArgumentTests.swift diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift new file mode 100644 index 000000000..ad857ed9c --- /dev/null +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ArgumentParser + +#if os(Windows) +import WinSDK +#endif + +let seedValue = "c6466b3a7881998f6bef30616fcd1769" + +@Suite struct InfoProvidingTests { + struct SeedCommand: ParsableCommand, InfoProvidingParsableCommand { + func run() { + Thread.sleep(forTimeInterval: 1.0) + } + + static func provideInfo() { + print("\(seedValue)") + Self.exit() + } + } +} + +extension InfoProvidingTests { + #if compiler(>=6.2) + @Test func siginfoHandled() async throws { + #if os(macOS) || os(FreeBSD) || os(OpenBSD) || os(Linux) || os(Android) || os(Windows) + let results = try await #require( + processExitsWith: .success, + observing: [\.standardOutputContent] + ) { + #if os(Linux) || os(Android) + // The default signal handler for SIGUSR1 aborts, and we are in a race to + // raise SIGUSR1 after the real signal handler has been set up, so ensure + // we're ignoring it instead until that happens. + signal(SIGUSR1, SIG_IGN) + #elseif os(Windows) + // As with Linux, Windows generates SIGBREAK by default. + SetConsoleCtrlHandler({ WindowsBool($0 == CTRL_BREAK_EVENT) }, true) + #endif + + try await withThrowingDiscardingTaskGroup { taskGroup in + taskGroup.addTask { + SeedCommand.main([]) + } + taskGroup.addTask { + // The main function is running asynchronously in another task, so we + // can't really predict when it will have set up the signal handler + // without plumbing through an invasive hook/callback. Instead, just + // spam ourselves with the signal. + while !Task.isCancelled { + #if os(macOS) || os(FreeBSD) || os(OpenBSD) + raise(SIGINFO) + #elseif os(Linux) || os(Android) + kill(getpid(), SIGUSR1) // ignore-unacceptable-language + #elseif os(Windows) + GenerateConsoleCtrlEvent(DWORD(CTRL_BREAK_EVENT), 0) + #endif + try await Task.yield() + } + } + } + } + + #expect(results.standardOutputContent.contains(seedValue.utf8)) + #else + try Test.cancel("Exit tests are unsupported on this platform") + #endif + } + #endif +}