Skip to content
62 changes: 62 additions & 0 deletions Examples/info-provider/InfoProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions Sources/ArgumentParser/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(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()
}
}
}
}
12 changes: 12 additions & 0 deletions Sources/ArgumentParser/Parsable Types/ParsableCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this pattern occurs at least twice, would it be justified to do:

withInfoHandler(...) {
  try command.run()
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. I'm not too too bothered by it but I can add a helper if other folks want it.


try command.run()
} catch {
exit(withError: error)
Expand Down
123 changes: 123 additions & 0 deletions Sources/ArgumentParser/Utilities/SIGINFOHandler.swift
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wish that we had a better name than SIGINFOHandler (perhaps InfoProviderHandler?).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not API so we can change it. We're thinking about using it for Ctrl+C too to support cancellation. But for now, it's an implementation detail so I'm not sweating it that much.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ControlKeyObserver maybe?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ControlKeyObserver maybe?

I really like this if you are okay with the length.

/// 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)
Comment thread
grynspan marked this conversation as resolved.
/// 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 }
}
}
}
1 change: 1 addition & 0 deletions Tests/ArgumentParserUnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ add_library(UnitTests
HelpGenerationTests+AtOption.swift
HelpGenerationTests+GroupName.swift
HelpGenerationTests+HelpBanner.swift
InfoProvidingTests.swift
NameSpecificationTests.swift
SerializedCompletionSuites.swift
SplitArgumentTests.swift
Expand Down
Loading