Skip to content

broken-circle/swift-subprocess

 
 

Repository files navigation

Subprocess

Subprocess is a cross-platform Swift package for spawning child processes, built from the ground up with Swift concurrency.

Getting Started

To use Subprocess in a SwiftPM project, add it as a package dependency in your Package.swift:

dependencies: [
    .package(
        url: "https://github.com/swiftlang/swift-subprocess.git",
        .upToNextMinor(from: "0.4.0")
    )
]

Then add the Subprocess module to your target dependencies:

.target(
    name: "MyTarget",
    dependencies: [
        .product(name: "Subprocess", package: "swift-subprocess")
    ]
)

Subprocess offers one package trait, SubprocessFoundation, which adds a dependency on Foundation and provides extensions on Foundation types like Data. This trait is enabled by default.

Swift Versions

We'd like this package to quickly embrace Swift language and toolchain improvements that are relevant to its mandate. Accordingly, from time to time, new versions of this package require clients to upgrade to a more recent Swift toolchain release. Patch (i.e., bugfix) releases will not increase the required toolchain version, but any minor (i.e., new feature) release may do so.

The following table maps package releases to their minimum required Swift toolchain:

Package version Swift version Xcode release
swift-subprocess 0.1.x >= Swift 6.1 >= Xcode 16.3
swift-subprocess 0.2.x >= Swift 6.1 >= Xcode 16.3
swift-subprocess 0.3.x >= Swift 6.1 >= Xcode 16.3
swift-subprocess 0.4.x >= Swift 6.1 >= Xcode 16.3
main >= Swift 6.2 >= Xcode 26

Feature Overview

Run and Collect Output

The simplest way to use Subprocess is to run a process and collect its output:

import Subprocess

let result = try await run(.name("ls"), output: .string(limit: 4096))

print(result.processIdentifier) // e.g. 1234
print(result.terminationStatus) // e.g. exited(0)
print(result.standardOutput)    // e.g. Optional("LICENSE\nPackage.swift\n...")

This returns an ExecutionResult containing the process identifier, termination status, and collected standard output and standard error.

Run with a Custom Closure

For more control, pass a closure that runs while the child process is active. The closure receives a single Execution value that you use to send signals, write to standard input, and stream standard output and standard error.

Caution

The Execution, AsyncBufferSequence, and StandardInputWriter values are valid only for the duration of the closure. Don't let them escape the closure.

You opt into each interactive stream by choosing the matching input or output type:

To do this... Pass this... Then read from...
Write to standard input from the closure input: .inputWriter execution.standardInputWriter
Stream standard output output: .sequence execution.standardOutput
Stream standard error error: .sequence execution.standardError

Stream standard output line by line:

import Subprocess

let result = try await run(
    .path("/usr/bin/tail"),
    arguments: ["-f", "/path/to/nginx.log"],
    input: .none,
    output: .sequence,
    error: .discarded
) { execution in
    for try await line in execution.standardOutput.strings() {
        if line.contains("500") {
            // Oh no, 500 error
        }
    }
}

Write to standard input and read from standard output:

let result = try await run(
    .name("cat"),
    input: .inputWriter,
    output: .sequence,
    error: .discarded
) { execution in
    async let reading: Void = {
        for try await line in execution.standardOutput.strings() {
            print(line) // "Hello, Subprocess!"
        }
    }()

    try await execution.standardInputWriter.write("Hello, Subprocess!\n")
    try await execution.standardInputWriter.finish()
    try await reading
}

The closure-based run returns an ExecutionResult. Access the closure's return value with result.closureOutput, and the termination status with result.terminationStatus.

Because input, output, and error are separate parameters, you can mix streaming and capturing in the same call. For example, stream standard output from the closure while collecting standard error as a string, and return the closure's own value through closureOutput:

let result = try await run(
    .path("/my/app"),
    input: .none,
    output: .sequence,
    error: .string(limit: 4096)
) { execution in
    var lineCount = 0
    for try await _ in execution.standardOutput.lines() {
        lineCount += 1
    }
    return lineCount
}

print(result.closureOutput)         // The line count returned from the closure.
print(result.standardError ?? "")   // The captured standard error.

Stream both standard output and standard error, writing to standard input from the same closure:

try await run(
    .path("/my/app"),
    input: .inputWriter,
    output: .sequence,
    error: .sequence
) { execution in
    try await withThrowingTaskGroup { group in
        group.addTask {
            for try await line in execution.standardOutput.lines() { /* ... */ }
        }
        group.addTask {
            for try await line in execution.standardError.lines() { /* ... */ }
        }
        group.addTask {
            _ = try await execution.standardInputWriter.write("Hello Subprocess")
            try await execution.standardInputWriter.finish()
        }
        try await group.waitForAll()
    }
}

In the closure-based API, output streams are delivered as an AsyncBufferSequence — an asynchronous sequence of Buffer values. Each Buffer provides access to its bytes via withUnsafeBytes(_:) or the bytes property (a RawSpan).

The preferred way to convert Buffer to String is to read output line by line using .lines(). You can optionally specify an encoding and buffering policy:

for try await line in execution.standardOutput.lines(
    encoding: UTF16.self,
    bufferingPolicy: .maxLineLength(1024)
) {
    // ...
}

StandardInputWriter supports writing [UInt8], String, RawSpan, and (with the SubprocessFoundation trait) Data. Call finish() when done writing.

Customizable Execution

Configure arguments, environment variables, and the working directory:

import Subprocess

let result = try await run(
    .path("/bin/ls"),
    arguments: ["-a"],
    // Inherit environment values from the parent process
    // and add NewKey=NewValue
    environment: .inherit.updating(["NewKey": "NewValue"]),
    workingDirectory: "/Users/",
    output: .string(limit: 4096)
)

For reusable configurations, construct a Configuration value directly:

let config = Configuration(
    .name("my-tool"),
    arguments: ["--verbose"],
    environment: .inherit
)
let result = try await run(config, output: .string(limit: 4096))

Input and Output Options

By default, Subprocess:

  • Provides no input to the child process
  • Discards the child process's standard error

For the collected-result API, you must specify how to capture standard output.

Input options:

Usage Description
.none No input (default)
.fileDescriptor(_:closeAfterSpawningProcess:) Read from a file descriptor
.standardInput Read from the parent process's standard input
.string(_:) or .string(_:using:) Read from a string with optional encoding
.array(_:) Read from a [UInt8] array
Span<BitwiseCopyable> Read from a span (passed directly as the input parameter)
.inputWriter Write from the closure via execution.standardInputWriter (closure-based run only)
.data(_:) Read from Data (requires SubprocessFoundation)
.sequence(_:) Read from a Sequence<Data> or AsyncSequence<Data> (requires SubprocessFoundation)

Output options:

Usage Description
.discarded Discard output
.fileDescriptor(_:closeAfterSpawningProcess:) Write to a file descriptor
.currentStandardOutput or .currentStandardError Write to the parent process's standard output or standard error
.string(limit:) or .string(limit:encoding:) Collect as String?
.bytes(limit:) Collect as [UInt8]
.sequence Stream to the closure via execution.standardOutput or execution.standardError (closure-based run only)
.data(limit:) Collect as Data (requires SubprocessFoundation)
.combinedWithOutput Merge standard error into the standard output stream (error parameter only)

The limit parameter specifies the maximum number of bytes to collect. Subprocess throws an error if the child process produces more output than the limit allows.

Use .combinedWithOutput for the error parameter to merge standard output and standard error into a single stream, equivalent to the shell redirection 2>&1:

let result = try await run(
    .name("my-tool"),
    output: .string(limit: 4096),
    error: .combinedWithOutput
)
// result.standardOutput contains both standard output and standard error

Graceful Teardown

When a parent task is cancelled, Subprocess can perform a configurable teardown sequence before forcefully terminating the child process. Set this up via PlatformOptions.teardownSequence:

let serverTask = Task {
    var platformOptions = PlatformOptions()
    platformOptions.teardownSequence = [
        .gracefulShutDown(allowedDurationToNextStep: .seconds(5))
    ]

    let result = try await run(
        .name("server"),
        platformOptions: platformOptions,
        output: .string(limit: 1024)
    )
}

// If serverTask is cancelled, Subprocess will:
// 1. Attempt a graceful shutdown (SIGTERM on Unix)
// 2. Wait up to 5 seconds for the process to exit
// 3. Send SIGKILL if the process hasn't exited
serverTask.cancel()

On Unix, you can also send specific signals as teardown steps:

platformOptions.teardownSequence = [
    .send(signal: .interrupt, allowedDurationToNextStep: .seconds(2)),
    .gracefulShutDown(allowedDurationToNextStep: .seconds(5))
]

The teardown sequence always concludes by sending a kill signal.

You can also trigger a teardown manually from within the closure via execution.teardown(using:), or send individual signals on Unix with execution.send(signal:).

Platform-Specific Options and Escape Hatches

PlatformOptions provides platform-specific settings for the child process:

  • Unix: userID, groupID, supplementaryGroups, processGroupID, createSession
  • macOS: All Unix options, plus qualityOfService and preSpawnProcessConfigurator
  • Windows: user credentials for starting the process as another user, console behavior, and window style

On macOS, preSpawnProcessConfigurator provides direct access to the underlying posix_spawn attributes and file actions:

import Darwin
import Subprocess

var platformOptions = PlatformOptions()
platformOptions.preSpawnProcessConfigurator = { spawnAttr, fileAttr in
    var flags: Int16 = 0
    posix_spawnattr_getflags(&spawnAttr, &flags)
    posix_spawnattr_setflags(&spawnAttr, flags | Int16(POSIX_SPAWN_SETSID))
}
let result = try await run(.path(...), platformOptions: platformOptions)

On Windows, preSpawnProcessConfigurator provides direct access to the underlying creation flags and startup info STARTUPINFOW used to call CreateProcessW:

import Darwin
import WinSDK

var platformOptions = PlatformOptions()
platformOptions.preSpawnProcessConfigurator = { creationFlags, startupInfo in
    creationFlags |= DWORD(CREATE_NEW_CONSOLE)
}
let result = try await run(.path(...), platformOptions: platformOptions)

See the PlatformOptions documentation for a complete list of configurable parameters on each platform.

Cross-Platform Support

Subprocess works on macOS, Linux, and Windows, with feature parity across all platforms as well as platform-specific options for each.

Platform Support Status
macOS Supported
Ubuntu 20.04 Supported
Ubuntu 22.04 Supported
Ubuntu 24.04 Supported
Red Hat Universal Base Image 9 Supported
Debian 12 Supported
Amazon Linux 2 Supported
Windows 11 Supported

Documentation

The latest API documentation can be viewed by running the following command:

swift package --disable-sandbox preview-documentation --target Subprocess

Contributing to Subprocess

Subprocess is part of the Foundation project. Discussion and evolution take place on the Swift Foundation Forum.

If you find something that looks like a bug, please open a Bug Report! Fill out as many details as you can.

Code of Conduct

Like all Swift.org projects, we would like the Subprocess project to foster a diverse and friendly community. We expect contributors to adhere to the Swift.org Code of Conduct.

Contact Information

The Foundation Workgroup communicates with the broader Swift community using the forum for general discussions.

The workgroup can also be contacted privately by messaging @foundation-workgroup on the Swift Forums.

About

Subprocess is a cross-platform package for spawning processes in Swift.

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Swift 92.8%
  • C 4.8%
  • CMake 2.2%
  • PowerShell 0.2%