Subprocess 1.0 is here. 馃帀 This release marks an important milestone for this package: the advent of source stability!
Subprocess is a cross-platform package for spawning processes in Swift, supporting macOS, Linux, Windows, FreeBSD, OpenBSD, and Android. It was first pitched as SF-0007 and shipped as a public beta in spring 2025. A year of community feedback later, SF-0037 reviewed the accumulated API changes, and this release makes them final.
This note has two parts. If you last looked at Subprocess when it went through Swift Evolution, start with What Changed Since SF-0007. If you're already on 1.0.0-beta.1, skip to What Changed Since 1.0.0-beta.1.
What Changed Since SF-0007
A single run() closure form, and a unified ExecutionResult
SF-0007 handled standard input out of step with the other two streams. Reading output meant iterating execution.standardOutput, but writing input meant reaching for a separate family of run() overloads whose closure took an extra StandardInputWriter argument. Every combination of "writes to standard input or not" needed its own overload, which produced a combinatorial explosion.
Execution is now generic over its Input type as well, so all three streams work the same way. Type-conditional extensions expose standardInputWriter, standardOutput, and standardError only when the matching stream is redirected:
public struct Execution<
Input: InputProtocol,
Output: OutputProtocol,
Error: OutputProtocol
>: Sendable {
public let processIdentifier: ProcessIdentifier
}
extension Execution where Input == CustomWriteInput {
public var standardInputWriter: StandardInputWriter { get }
}
extension Execution where Output == SequenceOutput {
public var standardOutput: SubprocessOutputSequence { get }
}
extension Execution where Error == SequenceOutput {
public var standardError: SubprocessOutputSequence { get }
}CustomWriteInput and SequenceOutput were previously internal; they and their .inputWriter and .sequence factories are now public. You opt into each stream independently: input: .inputWriter gives you execution.standardInputWriter, output: .sequence gives you execution.standardOutput, and error: .sequence gives you execution.standardError.
// Before: writing to standard input required a dedicated overload whose
// closure took an extra `StandardInputWriter`.
let result = try await run(.path("/bin/cat"), output: .sequence) { execution, writer in
_ = try await writer.write("Hello, world")
try await writer.finish()
for try await chunk in execution.standardOutput { ... }
}
// After: one closure form. Opt in with `input: .inputWriter` and reach the
// writer through `execution.standardInputWriter`.
let result = try await run(
.path("/bin/cat"),
input: .inputWriter,
output: .sequence,
error: .discarded
) { execution in
withTaskGroup { group in
group.addTask {
_ = try await execution.standardInputWriter.write("Hello, world")
try await execution.standardInputWriter.finish()
}
group.addTask {
for try await chunk in execution.standardOutput { ... }
}
}
}Because SF-0007 already exposed standardOutput and standardError conditionally, this leaves most call sites untouched since the visible change is concentrated on standard input. A call site that only reads output and error needs no changes at all.
The two result types are unified too. CollectedResult<Output, Error> is gone, and everything now flows through a single generic ExecutionResult:
public struct ExecutionResult<
ClosureResult: Sendable & ~Copyable,
Output: OutputProtocol,
Error: OutputProtocol
>: Sendable, ~Copyable {
public let processIdentifier: ProcessIdentifier
public let terminationStatus: TerminationStatus
public let standardOutput: Output.OutputType
public let standardError: Error.OutputType
public let closureResult: ClosureResult
}ClosureResult is Void for the collected run() overloads and the closure's return type otherwise. Beyond collapsing the overload set, this unlocks something that was previously impossible: collecting and streaming at the same time, since you're no longer forced to choose between two separate result types.
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.strings() {
lineCount += 1
}
return lineCount
}
print(result.closureResult) // Line count returned from the closure (streamed).
print(result.standardError) // Captured standard error (collected).The closure-based overloads require explicit input:, output:, and error: arguments. They have no defaults, so the compiler can determine which streaming properties the Execution value exposes. The collected overloads keep the familiar input: .none and error: .discarded defaults.
Two smaller changes round this out. Body closures may now return noncopyable values: the Result type parameter is ~Copyable, ExecutionResult is Copyable exactly when its ClosureResult is, and you move a move-only value out with the consuming takeClosureResult().
struct MoveOnlyResource: ~Copyable { /* ... */ }
let result = try await run(
.path("/usr/bin/my-tool"),
input: .none,
output: .discarded,
error: .discarded
) { execution -> MoveOnlyResource in
return MoveOnlyResource()
}
let resource = result.takeClosureResult()And Subprocess now adopts the NonisolatedNonsendingByDefault upcoming feature, which let us drop the isolation: isolated (any Actor)? = #isolation parameter from every closure-based run() overload.
Streaming output: SubprocessOutputSequence and StringSequence
Execution.standardOutput and standardError used to return an opaque some AsyncSequence<Buffer, any Swift.Error>. They now return a concrete, public SubprocessOutputSequence, with the element type re-nested as SubprocessOutputSequence.Buffer.
SubprocessOutputSequence owns the underlying OS pipe, so it is single-pass: calling makeAsyncIterator() more than once traps. The read buffer size is derived automatically from the platform's pipe buffer size. Buffer remains an immutable byte collection whose primary accessor is a RawSpan, and with the SubprocessFoundation trait enabled, Data gains an init(buffer:) that copies from one.
Streaming text is one of the most common things people do with Subprocess, and it was awkward before: naively converting each Buffer to a String breaks whenever a buffer boundary splits a multi-byte character. SubprocessOutputSequence.StringSequence handles the reassembly for you.
// Monitor an Nginx log via `tail -f`
let monitorResult = try await Subprocess.run(
.path("/usr/bin/tail"),
arguments: ["-f", "/path/to/nginx.log"],
output: .sequence,
error: .discarded
) { execution in
for try await line in execution.standardOutput.strings() {
if line.contains("500") {
// Oh no, 500 error
}
}
}You can create a StringSequence by calling .strings(separatedBy:bufferingPolicy:) on SubprocessOutputSequence. By default it splits on Unicode line breaks (LF, VT, FF, CR, CR+LF, NEL, LS, and PS), with separators excluded from the returned strings the way .split(separator:) behaves. You can supply your own delimiter with .unicodeScalarSequence(_:). Note that it matches at the code-unit level without Unicode normalization, so a precomposed "茅" (U+00E9) won't match a decomposed one (U+0065 U+0301). You can also pick a String encoding, and control back-pressure with a BufferingPolicy of either .unbounded or .maxLineLength(_:) (the default is 128 KB; exceeding it throws).
StringOutput.OutputType is now non-optional, and all output limits are explicit
StringOutput.OutputType was String? in SF-0007, on the theory that decoding raw bytes might fail. In practice the implementation used String(decoding:as:), which always succeeds by substituting the Unicode replacement character (U+FFFD), so the optional was never nil, and everyone paid an unwrap for a failure that couldn't happen. OutputType is now a non-optional String. You can still detect U+FFFD if you care about invalid input.
// Before: `standardOutput` is `String?`, so every access unwraps first.
let result = try await run(
.path("/bin/echo"),
arguments: ["Hello, world!"],
output: .string(limit: 1024)
)
guard let output = result.standardOutput else { return }
print(output.trimmingCharacters(in: .whitespacesAndNewlines))
// After: `standardOutput` is a non-optional `String`.
let result = try await run(
.path("/bin/echo"),
arguments: ["Hello, world!"],
output: .string(limit: 1024)
)
print(result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines))Relatedly, output factories now require an explicit limit. The zero-argument .string, .bytes, and .data conveniences silently capped collection at 128 KB; they're replaced by .string(limit:), .string(limit:encoding:), .bytes(limit:), and .data(limit:). Subprocess throws outputLimitExceeded when a process produces more than the limit. Because the default .string output is gone, the collected run() overloads now require an explicit output: argument. This design makes the maximum memory a run() call may allocate visible at the call site.
Error overhaul
SubprocessError had two problems: its Code was an opaque Int that you had to memorize, and the library never formalized what it throws or how you should catch it.
SubprocessError.Code is now a proper type with named static properties: .spawnFailed, .executableNotFound, .failedToChangeWorkingDirectory, .failedToMonitorProcess, .failedToReadFromSubprocess, .failedToWriteToSubprocess, .outputLimitExceeded, .asyncIOFailed, and .processControlFailed.
On Windows, WindowsError is no longer a thin wrapper around a single GetLastError() DWORD. Windows surfaces errors through several distinct subsystems, so it's now an enum with .ntStatus, .win32, .hresult, and .cRuntime cases.
The throwing contract is formalized as well: Subprocess itself only ever throws SubprocessError since most internals now use typed throws. The only other errors you'll see are the ones you throw yourself from a body closure or from .preSpawnProcessConfigurator. That gives error handling a clean shape:
do {
let result = try await run(...) { execution in
// You can throw anything you like from here
throw MyError()
}
} catch let subprocessError as SubprocessError {
// Something went wrong in the environment, or in Subprocess itself.
switch subprocessError.code {
case .spawnFailed:
...
}
} catch let myError as MyError {
// Your own errors from the closure
}Environment.Key
Environment keys are case-insensitive on Windows and case-sensitive everywhere else. Raw String keys papered over that difference whereas a dedicated Environment.Key type respects each platform's rules. It's ExpressibleByStringLiteral, so literals keep working unchanged.
extension Environment {
public struct Key: Codable, Hashable, ExpressibleByStringLiteral, Sendable {
public var rawValue: String
}
}The Environment methods that took [String: String] now take [Key: ...]. updating(_:) additionally accepts nil values, so you can remove an inherited variable before it reaches the child:
public struct Environment: Sendable, Hashable {
public static var inherit: Self { get }
/// A `nil` value removes the corresponding key from the inherited environment.
public func updating(_ newValue: [Key: String?]) -> Self
public static func custom(_ newValue: [Key: String]) -> Self
#if !os(Windows)
public static func custom(_ newValue: [[UInt8]]) -> Self
#endif
}Combining standard error into standard output
Merging the two streams the way 2>&1 does is a common enough request that it now has a first-class spelling: .combinedWithOutput.
let result = try await run(
.path("/bin/sh"),
arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"],
output: .string(limit: 1024),
error: .combinedWithOutput
)
// result.standardOutput == "Hello Stdout\nHello Stderr"This required expanding the protocol hierarchy. Every other output type works for either stream, but CombinedErrorOutput only makes sense for standard error. So there's now an ErrorOutputProtocol that refines OutputProtocol and adds no new requirements, and the error: parameter of run() is constrained to it. All the built-in output types conform to both, so they still work for either stream, only CombinedErrorOutput is error-only.
Redirecting to the parent's streams
FileDescriptorOutput gains .currentStandardOutput and .currentStandardError, which forward the child's output to the parent's own streams. This feature is useful when you want to follow along with a process rather than capture it. Symmetrically, FileDescriptorInput gains .currentStandardInput, which feeds the child the parent's standard input. None of these close the underlying descriptor afterward.
Process identity, termination, and the removal of runDetached
runDetached() is removed. It was pitched as an escape hatch for spawning synchronously where concurrency might be unavailable. It was designed to be a thin wrapper over posix_spawn that returned a child PID and did no async I/O or state monitoring. The problem is PID reuse. On Windows a PID has no concept of wait() and reaping, and can be recycled the instant the process terminates, so the PID may already be invalid by the time runDetached() returns. Rather than build an elaborate workaround for a TOCTOU race in an API that was never core to Subprocess, we removed it.
To address that same PID-reuse hazard in the API that remains, ProcessIdentifier now exposes platform-specific process descriptors. On Linux, Android, and FreeBSD it carries a processDescriptor: CInt (a pidfd on Linux) alongside value: pid_t; on Windows it carries a processDescriptor: HANDLE and a threadHandle: HANDLE. Darwin continues to wrap just the pid_t. We recommend using the descriptor rather than the raw PID. Per the Linux documentation, even if the child has already terminated by the time of the pidfd_open() call, its PID will not have been recycled and the descriptor refers to the resulting zombie.
TerminationStatus is redesigned for Windows. Its .exited() / .unhandledException() split reflected Unix's wait(2) bitfield, which distinguishes normal exits from signals. Windows's GetExitCodeProcess() returns a single DWORD, so that distinction can't be reconstructed. .unhandledException() is therefore removed on Windows, and renamed to .signaled() on Unix, where signal delivery is what actually happened.
public enum TerminationStatus: Sendable, Hashable {
#if os(Windows)
public typealias Code = DWORD
#else
public typealias Code = CInt
#endif
case exited(Code)
#if !os(Windows)
case signaled(Code)
#endif
public var isSuccess: Bool
}Both ProcessIdentifier and TerminationStatus are now Sendable, Hashable; their SF-0007 Codable conformances are removed, since process descriptors are process-local and not meaningfully serializable.
Teardown
TeardownStep.sendSignal(_:allowedDurationToNextStep:) is renamed to .send(signal:toProcessGroup:allowedDurationToNextStep:), and .gracefulShutDown(...) gains the same toProcessGroup parameter (and a fix for the misspelled alloweDurationToNextStep label). Targeting the process group means descendants don't leak after teardown, and the implicit final .kill step inherits toProcessGroup from the last explicit step.
await execution.teardown(using: [
.send(signal: .quit, allowedDurationToNextStep: .milliseconds(100)),
.send(signal: .terminate, allowedDurationToNextStep: .milliseconds(100)),
])PlatformOptions
On all platforms, PlatformOptions no longer conforms to Hashable. It's now Sendable plus CustomStringConvertible/CustomDebugStringConvertible. The closure-valued escape-hatch properties never had a meaningful Hashable implementation, so the conformance was misleading.
On Darwin, launchRequirementData is removed; it was never wired up to a supported launch path.
On Linux and other non-Darwin Unix platforms, the preSpawnProcessConfigurator escape hatch is removed. It runs between fork and exec, where only async-signal-safe work is permitted, and we can't offer that safely as a public API. It remains available on Darwin (operating on posix_spawnattr_t / posix_spawn_file_actions_t) and on Windows (operating on dwCreationFlags / STARTUPINFOW). The non-Darwin Unix options are now:
public struct PlatformOptions: Sendable {
public var userID: uid_t? = nil
public var groupID: gid_t? = nil
public var supplementaryGroups: [gid_t]? = nil
public var processGroupID: pid_t? = nil
public var createSession: Bool = false
public var teardownSequence: [TeardownStep] = []
public init() {}
}On Windows, UserCredentials and the userCredentials property are internal for 1.0 while their behavior is finalized, and the misspelled ConsoleBehavior.detatch is corrected to .detach.
Swift 6.2 is now required
Subprocess was designed around Span as the currency type for file I/O, but we wanted Swift 6.1 to work at beta time so more people could try it. That meant shims and workarounds behind a SubprocessSpan trait. Swift 6.2 has been out for over a year, so 1.0 drops the workarounds: the package requires swift-tools-version: 6.2.
If you need Swift 6.1, use the 0.4 tag as it's the final version of Subprocess that supports it.
This removes the SubprocessSpan trait and the Sequence<UInt8>-based fallback on OutputProtocol, leaving RawSpan as the single currency type. OutputProtocol and InputProtocol also gain a ~Copyable relaxation, so noncopyable types can conform.
-public protocol OutputProtocol: Sendable {
+public protocol OutputProtocol: Sendable, ~Copyable {
associatedtype OutputType: Sendable
/// Convert the output from span to expected output type
func output(from span: RawSpan) throws -> OutputType
-
- /// Convert the output from buffer to expected output type
- func output(from buffer: some Sequence<UInt8>) throws -> OutputType
var maxSize: Int { get }
}Other refinements
Configurationis no longerHashable/Equatable. It's nowSendableplusCustomStringConvertible/CustomDebugStringConvertible, consistent withPlatformOptions. Its initializer label changes frominit(executing:)toinit(executable:), andworkingDirectorybecomes anOptional<FilePath>stored property, wherenilinherits the parent's working directory.Executable.resolveExecutablePath(in:)is nowasyncand uses typed throws since resolving a path may touch the filesystem on a background thread.StandardInputWriterwrite methods adopt typed throws (throws(SubprocessError)), and theRawSpanoverload is now unconditionally available rather than gated on the removedSubprocessSpantrait.
What Changed Since 1.0.0-beta.1
API Changes
Executable.name(_:) searches PATH and nothing else, on every platform (#357)
Executable.name(_:) is documented as a PATH lookup, but the resolver also searched a current directory, ahead of PATH, and the details differed between the eager resolveExecutablePath(in:) and the spawn path, and between Unix and Windows. On Unix a bare ./tool beat every PATH entry. This is the classic dot-in-PATH hazard, which turns "clone this repo and run the tool" into arbitrary code execution when a checkout contains a file named git, swift, or make. On Windows the search was CreateProcessW's, which covers the application directory, the current directory, and the system directories before PATH, and which reads PATH from the calling process rather than from the environment you pass to the subprocess.
.name(_:) now means one thing everywhere: walk the directories listed in PATH, in order, and run the first match.
- The
PATHsearched is the subprocess's. The value from the environment you pass torunwins, so a name resolves in the environment it will run in. When that environment sets noPATH, the current process's value is used. Windows no longer delegates the search toCreateProcessW, which would otherwise ignore thePATHinlpEnvironment, matching what Node.js and Rust do, rather than shipping the divergence as Go and Python do. - No current directory is searched. Not the calling process's, and not the
workingDirectoryyou pass torun. EmptyPATHentries (from a leading, trailing, or doubled separator) and relative entries are skipped, since both are a current-directory search by another name. Every resolved path is therefore absolute, andresolveExecutablePath(in:)and the spawn path agree on which executable a configuration names. - Windows no longer searches the application directory or the system directories when a
PATHexists. A helper executable shipped next to your app is no longer found by name; name it withpath(_:)instead. - A name containing a path separator is rejected with a
SubprocessErrorwhose code is.spawnFailed, rather than being resolved against a current directory./counts on every platform;\and:also count on Windows.
// Before: could run ./tool from the current directory, or a helper next to the
// app on Windows, in preference to the tool on PATH.
try await run(.name("tool"), output: .string(limit: 1024))
// After: `.name` searches PATH only. To run an executable at a location, name
// the location.
try await run(.path("/usr/local/bin/tool"), output: .string(limit: 1024))The PATH-less fallback now asks the system for the default PATH instead of using a hard-coded list. When neither the subprocess environment nor the current process defines PATH at all, Unix-like platforms perform the following fallback PATH resolution: 1) First, search confstr(_CS_PATH), which is the same standard path execvp(3) uses and what getconf PATH prints; 2) then, fall back to the <paths.h> macro; 3) finally, fall back to a hard-coded list (/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin).
Windows searches the directories CreateProcessW searches on its own: the application directory, the 32-bit and 16-bit system directories, and the Windows directory, with the current directory left out. Two Windows-only improvements fall out of resolving the name ourselves. resolveExecutablePath(in:) now applies PATHEXT, so .name("cmd") resolves rather than requiring .name("cmd.exe"); and a PATHEXT extension is appended rather than substituted, so .name("python3.11") looks for python3.11 and then python3.11.exe instead of python3.exe. A name that resolves to a .bat or .cmd runs through the hardened cmd.exe invocation added for CVE-2024-24576, so .name("npm") finding npm.cmd is safe.
StringOutput.OutputType is now a non-optional String (#338)
StringOutput.output(from:) never actually returned nil. It decodes through String(decoding:as:), which always succeeds by substituting U+FFFD for invalid byte sequences. The optional OutputType therefore imposed an unwrap for a case that could not occur. It's now String, which also makes StringOutput consistent with DataOutput, whose OutputType was already a non-optional Data.
// Before
let result = try await run(.path("/bin/echo"), arguments: ["hi"], output: .string(limit: 1024))
guard let output = result.standardOutput else { return } // `String?`
print(output)
// After
let result = try await run(.path("/bin/echo"), arguments: ["hi"], output: .string(limit: 1024))
print(result.standardOutput) // `String`Invalid bytes still become U+FFFD, so you can check for the replacement character if your input may not be well-formed text.
Deprecated FileDescriptorOutput aliases removed (#349)
.standardOutput and .standardError were reintroduced in beta.1 as @available(*, deprecated, renamed:) aliases to ease the transition. They're now removed for 1.0.
// Before (deprecated in beta.1)
try await run(.path("/bin/ls"), output: .standardOutput, error: .standardError)
// After
try await run(.path("/bin/ls"), output: .currentStandardOutput, error: .currentStandardError)InputProtocol.standardInput renamed to .currentStandardInput (#356)
The input side was missed when the output properties were renamed to the current* spelling. It's renamed now so all three parent-stream redirections read the same way.
// Before
let result = try await run(
.path("/bin/cat"),
input: .standardInput,
output: .string(limit: 256)
)
// After
let result = try await run(
.path("/bin/cat"),
input: .currentStandardInput,
output: .string(limit: 256)
)Bug Fixes
- Call
setgid()beforesetuid()when spawning (#344). Setting bothuserIDandgroupIDinPlatformOptionsfailed to spawn withEPERM. Oncesetuid()drops the effective UID out of the superuser, the kernel clears the permitted capability set on Linux and the saved-set-ID rules bite on Darwin and the BSDs, so the subsequentsetgid()was no longer permitted. The calls are reordered in both spawn paths. Resolves #342. - Tolerate spurious wakeups on the background worker thread (#350). Subprocess spawns run on a shared background worker that sleeps on a POSIX condition variable while its queue is empty.
pthread_cond_waitis permitted to return without a matching signal, and does so on Linux when the waiting thread is interrupted by a signal. The wait now loops on the predicate instead of branching once, so a spurious wakeup re-checks and goes back to sleep rather than letting the worker exit early and hang callers with leaked continuations. A shutdown flag keeps the worker able to wake and exit. Resolves #348. - Fix a use-after-free when the Windows monitor fails with pending I/O (#340). Follow-up to #325 and #336, which fixed this class of lifetime bug on the read and write cancellation paths. On the branch where the signal stream throws, both
issueAndAwaitRead()andwrite(_:to:for:)rethrew with an overlapped operation still pending against the caller's buffer, which the kernel could then write into (or read from) after the frame unwound. Bothcatchbranches now route throughsettlePendingOverlapped(), cancelling withCancelIoEx()and waiting withGetOverlappedResult()before the buffer goes out of scope.
Documentation & Infrastructure
- Deeper DocC curation, organizing symbols across all types, and revised wording toward less formal structures. by @heckj in #339
- New articles covering
run()and its more common inputs and patterns, including collecting results, searching for executables versus supplying a path, and stream processing. by @heckj in #347 - Document the
SubprocessOutputSequenceprecondition that creating a second iterator is a fatal error. by @broken-circle in #352 - Remove the explicit
swift-docc-plugindependency, which unbreaks documentation builds in swiftlang/docs and other tooling. by @ktoso in #354 - Migrate CI from Amazon Linux 2, which reached end of life on 2026-06-30 and no longer receives nightly toolchains, to Amazon Linux 2023. by @broken-circle in #335
- Bump
swiftlang/github-workflowssoundness workflow to 0.0.12 and 0.0.13. by @dependabot in #341, #355
Detailed Change List
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from 0.0.11 to 0.0.12 in the swiftlang-actions group by @dependabot[bot] in #341
- Call
setgid()beforesetuid()in the spawned subprocess by @broken-circle in #344 - Deeper curation, revising some wording to less formal structures by @heckj in #339
- Narrow
StringOutput.OutputTypeto non-optional by @broken-circle in #338 - Remove deprecated
FileDescriptorOutputaliases by @broken-circle in #349 - Migrate CI from Amazon Linux 2 to Amazon Linux 2023 by @broken-circle in #335
- Tolerate spurious wakeups on the background worker thread by @iCharlesHu in #350
- Fix use-after-free when Windows monitor fails with pending I/O by @broken-circle in #340
- Document
SubprocessOutputSequenceprecondition by @broken-circle in #352 - Remove explicit swift-docc-plugin dependency by @ktoso in #354
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from 0.0.12 to 0.0.13 in the swiftlang-actions group by @dependabot[bot] in #355
- Draft articles, providing details about using subprocess run and its more common inputs and patterns by @heckj in #347
- Rename
InputProtocol.standardInputtocurrentStandardInputby @iCharlesHu in #356
Full Changelog: 1.0.0-beta.1...1.0.0