From 29826f684206d3ef03bc6c68bbac051e05afd6be Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 10:21:13 -0400 Subject: [PATCH 01/12] Provide an interface for tools to implement `SIGINFO` handling. This PR adds a new protocol, `InfoProvidingCommand`, to which root command types can conform to indicate they respond to `SIGINFO` or platform equivalents: - On Darwin and the BSDs, the shell raises `SIGINFO` when you press Ctrl+T; - On Linux, `SIGUSR1` is used by (weak) convention for this purpose and can be raised with `kill` or `pkill`. - On Windows, Ctrl+Break serves the same purpose and triggers a Win32-specific callback. --- Examples/info-provider/InfoProvider.swift | 62 +++++++++ Package.swift | 5 + Sources/ArgumentParser/CMakeLists.txt | 2 + .../Parsable Types/AsyncParsableCommand.swift | 12 ++ .../Parsable Types/InfoProvidingCommand.swift | 70 ++++++++++ .../Parsable Types/ParsableCommand.swift | 12 ++ .../Utilities/SIGINFOHandler.swift | 123 ++++++++++++++++++ Tests/ArgumentParserUnitTests/CMakeLists.txt | 1 + .../InfoProvidingTests.swift | 79 +++++++++++ 9 files changed, 366 insertions(+) create mode 100644 Examples/info-provider/InfoProvider.swift create mode 100644 Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift create mode 100644 Sources/ArgumentParser/Utilities/SIGINFOHandler.swift create mode 100644 Tests/ArgumentParserUnitTests/InfoProvidingTests.swift diff --git a/Examples/info-provider/InfoProvider.swift b/Examples/info-provider/InfoProvider.swift new file mode 100644 index 000000000..1a0e9eada --- /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 + 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..397d3f2cf 100644 --- a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift @@ -65,6 +65,18 @@ extension AsyncParsableCommand { public static func main(_ arguments: [String]?) async { do { var command = try await asyncParseAsRoot(arguments) + + var siginfoHandler: SIGINFOHandler? + if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *), + 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..fba00c93c --- /dev/null +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +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) func provideInfo() async + #else + func provideInfo() async + #endif +} + +// MARK: - + +@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +extension SIGINFOHandler { + convenience init(for command: T) where T: InfoProvidingParsableCommand { + self.init { + #if compiler(>=6.3) + _ = Task.immediate { + await command.provideInfo() + } + #else + _ = Task { + await command.provideInfo() + } + #endif + } + } +} diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index 1f2cd2e50..cbce2452c 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 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *), + 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..c75ac54a0 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@Suite struct InfoProvidingTests { + struct SeedCommand: InfoProvidingParsableCommand { + @Argument var seedValue: String + + func run() { + while true { + Thread.sleep(forTimeInterval: 1.0) + } + } + + func provideInfo() { + print("\(seedValue)") + Self.exit() + } + } +} + +extension InfoProvidingTests { + static let seedValue = "c6466b3a7881998f6bef30616fcd1769" + + #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) + #endif + + try await withThrowingDiscardingTaskGroup { taskGroup in + taskGroup.addTask { + SeedCommand.main([Self.seedValue]) + } + 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 true { + #if os(macOS) || os(FreeBSD) || os(OpenBSD) + raise(SIGINFO) + #elseif os(Linux) || os(Android) + kill(getpid(), SIGUSR1) // ignore-unacceptable-language + #elseif os(Windows) + GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, 0) + #endif + } + } + } + } + + #expect(results.standardOutputContent.contains(Self.seedValue.utf8)) + #else + try Test.cancel("Exit tests are unsupported on this platform") + #endif + } + #endif +} From 6ccd2b1dca3c910c057e2feb132378b56bc50800 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 13:59:38 -0400 Subject: [PATCH 02/12] Missing Windows import --- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index c75ac54a0..ea055ca77 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -12,6 +12,10 @@ import Foundation import Testing +#if os(Windows) +import WinSDK +#endif + @testable import ArgumentParser @Suite struct InfoProvidingTests { From 436fba65c75271c68bb52f97da7ecbbaebb444a5 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 14:05:45 -0400 Subject: [PATCH 03/12] Lower availability constraint on the impl --- .../Parsable Types/AsyncParsableCommand.swift | 3 +-- .../Parsable Types/InfoProvidingCommand.swift | 14 ++++++++------ .../Parsable Types/ParsableCommand.swift | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift index 397d3f2cf..1c44694fe 100644 --- a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift @@ -67,8 +67,7 @@ extension AsyncParsableCommand { var command = try await asyncParseAsRoot(arguments) var siginfoHandler: SIGINFOHandler? - if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *), - let command = command as? any InfoProvidingParsableCommand + if let command = command as? any InfoProvidingParsableCommand { siginfoHandler = SIGINFOHandler(for: command) } diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift index fba00c93c..317f539ee 100644 --- a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -26,7 +26,7 @@ /// /// On platforms that do not support providing information, conformance to this /// protocol has no effect. -@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +@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 @@ -52,18 +52,20 @@ public protocol InfoProvidingParsableCommand: Sendable, ParsableCommand { // MARK: - -@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) extension SIGINFOHandler { convenience init(for command: T) where T: InfoProvidingParsableCommand { self.init { + guard #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) else { + _ = Task { + await command.provideInfo() + } + return + } #if compiler(>=6.3) _ = Task.immediate { await command.provideInfo() } - #else - _ = Task { - await command.provideInfo() - } #endif } } diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index cbce2452c..e74120594 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -160,7 +160,7 @@ extension ParsableCommand { var command = try parseAsRoot(arguments) var siginfoHandler: SIGINFOHandler? - if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *), + if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *), let command = command as? any InfoProvidingParsableCommand { siginfoHandler = SIGINFOHandler(for: command) From 6f067dc5995ba8ad09becf9537167646086ac2eb Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 14:08:42 -0400 Subject: [PATCH 04/12] Booooo linter --- .../Parsable Types/AsyncParsableCommand.swift | 3 +-- .../Parsable Types/InfoProvidingCommand.swift | 3 ++- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift index 1c44694fe..2fce3d168 100644 --- a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift @@ -67,8 +67,7 @@ extension AsyncParsableCommand { var command = try await asyncParseAsRoot(arguments) var siginfoHandler: SIGINFOHandler? - if let command = command as? any InfoProvidingParsableCommand - { + if let command = command as? any InfoProvidingParsableCommand { siginfoHandler = SIGINFOHandler(for: command) } siginfoHandler?.register() diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift index 317f539ee..c3d60a40e 100644 --- a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -56,7 +56,8 @@ public protocol InfoProvidingParsableCommand: Sendable, ParsableCommand { extension SIGINFOHandler { convenience init(for command: T) where T: InfoProvidingParsableCommand { self.init { - guard #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) else { + guard #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + else { _ = Task { await command.provideInfo() } diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index ea055ca77..78509567c 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -12,12 +12,12 @@ import Foundation import Testing +@testable import ArgumentParser + #if os(Windows) import WinSDK #endif -@testable import ArgumentParser - @Suite struct InfoProvidingTests { struct SeedCommand: InfoProvidingParsableCommand { @Argument var seedValue: String @@ -65,7 +65,7 @@ extension InfoProvidingTests { #if os(macOS) || os(FreeBSD) || os(OpenBSD) raise(SIGINFO) #elseif os(Linux) || os(Android) - kill(getpid(), SIGUSR1) // ignore-unacceptable-language + kill(getpid(), SIGUSR1) // ignore-unacceptable-language #elseif os(Windows) GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, 0) #endif From 64b3075c159fdee3a62be5caff2b96db49147bc5 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 14:20:43 -0400 Subject: [PATCH 05/12] DWORD forever --- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index 78509567c..2bd91748c 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -67,7 +67,7 @@ extension InfoProvidingTests { #elseif os(Linux) || os(Android) kill(getpid(), SIGUSR1) // ignore-unacceptable-language #elseif os(Windows) - GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, 0) + GenerateConsoleCtrlEvent(DWORD(CTRL_BREAK_EVENT), 0) #endif } } From 22f309a6ba253b268662348fe7b1919272ceff1c Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 14:46:28 -0400 Subject: [PATCH 06/12] Avoid SIGBREAK on Windows --- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index 2bd91748c..bc8138581 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -50,6 +50,9 @@ extension InfoProvidingTests { // 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({ $0 == CTRL_BREAK_EVENT }, true) #endif try await withThrowingDiscardingTaskGroup { taskGroup in From d28b8ba0520a98bde4ea7f54ada9e55d67975d5d Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 15:03:20 -0400 Subject: [PATCH 07/12] The sighs continue --- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index bc8138581..554cb5217 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -52,7 +52,7 @@ extension InfoProvidingTests { signal(SIGUSR1, SIG_IGN) #elseif os(Windows) // As with Linux, Windows generates SIGBREAK by default. - SetConsoleCtrlHandler({ $0 == CTRL_BREAK_EVENT }, true) + SetConsoleCtrlHandler({ WindowsBool($0 == CTRL_BREAK_EVENT) }, true) #endif try await withThrowingDiscardingTaskGroup { taskGroup in From 79f568de08ac29e312e67e3764de39d62a313b22 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 15:36:18 -0400 Subject: [PATCH 08/12] Avoid blocking thread pool threads --- .../InfoProvidingTests.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index 554cb5217..77d1b2b0f 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -19,12 +19,12 @@ import WinSDK #endif @Suite struct InfoProvidingTests { - struct SeedCommand: InfoProvidingParsableCommand { + struct SeedCommand: AsyncParsableCommand, InfoProvidingParsableCommand { @Argument var seedValue: String - func run() { - while true { - Thread.sleep(forTimeInterval: 1.0) + func run() async { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) } } @@ -57,14 +57,14 @@ extension InfoProvidingTests { try await withThrowingDiscardingTaskGroup { taskGroup in taskGroup.addTask { - SeedCommand.main([Self.seedValue]) + await SeedCommand.main([Self.seedValue]) } 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 true { + while !Task.isCancelled { #if os(macOS) || os(FreeBSD) || os(OpenBSD) raise(SIGINFO) #elseif os(Linux) || os(Android) @@ -72,6 +72,7 @@ extension InfoProvidingTests { #elseif os(Windows) GenerateConsoleCtrlEvent(DWORD(CTRL_BREAK_EVENT), 0) #endif + await Task.yield() } } } From 26ddcf1f8c97d4013262192a6aa5de3bbdd94fae Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 16:19:32 -0400 Subject: [PATCH 09/12] Actually suspend the kill loop --- Tests/ArgumentParserUnitTests/InfoProvidingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index 77d1b2b0f..061c7a531 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -72,7 +72,7 @@ extension InfoProvidingTests { #elseif os(Windows) GenerateConsoleCtrlEvent(DWORD(CTRL_BREAK_EVENT), 0) #endif - await Task.yield() + try await Task.sleep(nanoseconds: 100_000_000) } } } From e1ab215033de356f94496ea52a5ba9e1a00334e6 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 16:34:44 -0400 Subject: [PATCH 10/12] Try detached tasks, the problem is only on 6.2 where we can't call .immediate --- .../ArgumentParser/Parsable Types/InfoProvidingCommand.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift index c3d60a40e..2f9e1cadf 100644 --- a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -58,13 +58,13 @@ extension SIGINFOHandler { self.init { guard #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) else { - _ = Task { + _ = Task.detached { await command.provideInfo() } return } #if compiler(>=6.3) - _ = Task.immediate { + _ = Task.immediateDetached { await command.provideInfo() } #endif From a6deea6f742965466c456154e2f854f0da7caa4c Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 16:43:32 -0400 Subject: [PATCH 11/12] Genius, you is --- .../Parsable Types/InfoProvidingCommand.swift | 12 ++++++------ .../ArgumentParserUnitTests/InfoProvidingTests.swift | 12 +++++------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift index 2f9e1cadf..d3a2532ab 100644 --- a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -56,18 +56,18 @@ public protocol InfoProvidingParsableCommand: Sendable, ParsableCommand { extension SIGINFOHandler { convenience init(for command: T) where T: InfoProvidingParsableCommand { self.init { - guard #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) - else { - _ = Task.detached { + #if compiler(>=6.3) + if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) { + _ = Task.immediate { await command.provideInfo() } return } - #if compiler(>=6.3) - _ = Task.immediateDetached { + #endif + + _ = Task { await command.provideInfo() } - #endif } } } diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index 061c7a531..fc1c917be 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -19,13 +19,11 @@ import WinSDK #endif @Suite struct InfoProvidingTests { - struct SeedCommand: AsyncParsableCommand, InfoProvidingParsableCommand { + struct SeedCommand: ParsableCommand, InfoProvidingParsableCommand { @Argument var seedValue: String - func run() async { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - } + func run() { + Thread.sleep(forTimeInterval: 1.0) } func provideInfo() { @@ -57,7 +55,7 @@ extension InfoProvidingTests { try await withThrowingDiscardingTaskGroup { taskGroup in taskGroup.addTask { - await SeedCommand.main([Self.seedValue]) + SeedCommand.main([Self.seedValue]) } taskGroup.addTask { // The main function is running asynchronously in another task, so we @@ -72,7 +70,7 @@ extension InfoProvidingTests { #elseif os(Windows) GenerateConsoleCtrlEvent(DWORD(CTRL_BREAK_EVENT), 0) #endif - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.yield() } } } From c06042e29896be1dfcfe96047872a6ad20afeeec Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 1 Sep 2026 17:27:23 -0400 Subject: [PATCH 12/12] Make it static since it cannot concurrently access the instance anyway --- Examples/info-provider/InfoProvider.swift | 2 +- .../Parsable Types/InfoProvidingCommand.swift | 8 ++++---- .../ArgumentParserUnitTests/InfoProvidingTests.swift | 12 +++++------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Examples/info-provider/InfoProvider.swift b/Examples/info-provider/InfoProvider.swift index 1a0e9eada..273bca72c 100644 --- a/Examples/info-provider/InfoProvider.swift +++ b/Examples/info-provider/InfoProvider.swift @@ -46,7 +46,7 @@ struct InfoProvider: AsyncParsableCommand, InfoProvidingParsableCommand { } @MainActor - func provideInfo() { + static func provideInfo() { let timeRunning = Date().timeIntervalSince(info.start) print("Running for \(timeRunning) seconds.") diff --git a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift index d3a2532ab..e10f92666 100644 --- a/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/InfoProvidingCommand.swift @@ -44,9 +44,9 @@ public protocol InfoProvidingParsableCommand: Sendable, ParsableCommand { /// 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) func provideInfo() async + nonisolated(nonsending) static func provideInfo() async #else - func provideInfo() async + static func provideInfo() async #endif } @@ -59,14 +59,14 @@ extension SIGINFOHandler { #if compiler(>=6.3) if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) { _ = Task.immediate { - await command.provideInfo() + await T.provideInfo() } return } #endif _ = Task { - await command.provideInfo() + await T.provideInfo() } } } diff --git a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift index fc1c917be..ad857ed9c 100644 --- a/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift +++ b/Tests/ArgumentParserUnitTests/InfoProvidingTests.swift @@ -18,15 +18,15 @@ import Testing import WinSDK #endif +let seedValue = "c6466b3a7881998f6bef30616fcd1769" + @Suite struct InfoProvidingTests { struct SeedCommand: ParsableCommand, InfoProvidingParsableCommand { - @Argument var seedValue: String - func run() { Thread.sleep(forTimeInterval: 1.0) } - func provideInfo() { + static func provideInfo() { print("\(seedValue)") Self.exit() } @@ -34,8 +34,6 @@ import WinSDK } extension InfoProvidingTests { - static let seedValue = "c6466b3a7881998f6bef30616fcd1769" - #if compiler(>=6.2) @Test func siginfoHandled() async throws { #if os(macOS) || os(FreeBSD) || os(OpenBSD) || os(Linux) || os(Android) || os(Windows) @@ -55,7 +53,7 @@ extension InfoProvidingTests { try await withThrowingDiscardingTaskGroup { taskGroup in taskGroup.addTask { - SeedCommand.main([Self.seedValue]) + SeedCommand.main([]) } taskGroup.addTask { // The main function is running asynchronously in another task, so we @@ -76,7 +74,7 @@ extension InfoProvidingTests { } } - #expect(results.standardOutputContent.contains(Self.seedValue.utf8)) + #expect(results.standardOutputContent.contains(seedValue.utf8)) #else try Test.cancel("Exit tests are unsupported on this platform") #endif