Cross-platform subprocess spawning, output capture, and lifecycle management — without importing Foundation.
Run a child process, capture both output streams, bound its runtime, and switch over a typed exit status:
import Process
let output = try Process.Spawn.run(
Process.Spawn.Configuration(
executable: "/usr/bin/git",
arguments: ["status", "--porcelain"],
stdout: .pipe,
stderr: .pipe,
workingDirectory: "/path/to/repo",
timeout: .seconds(30)
)
)
switch output.status {
case .exited(code: 0):
let listing = String(decoding: output.stdout ?? [], as: UTF8.self)
print(listing)
case .exited(let code):
let diagnostics = String(decoding: output.stderr ?? [], as: UTF8.self)
print("git failed (\(code)): \(diagnostics)")
case .signaled(let signal):
print("killed by signal \(signal)") // timeout expiry reports SIGKILL here
case .stopped(let signal):
print("stopped by signal \(signal)")
}Two behaviors in this example are easy to get wrong when driving posix_spawn(3) or CreateProcessW by hand:
- Deadlock-free dual capture. When both
stdoutandstderrare piped, the parent drains them concurrently viapoll(2)on POSIX. A child that fills the kernel's pipe buffer (typically 64 KiB) on one stream while the parent is still reading the other completes instead of wedging — the classic two-pipe deadlock. - Bounded runtime. A non-
niltimeoutarms a watchdog that sendsSIGKILLwhen the deadline elapses (POSIX). The result reports.signaledwith the platform'sSIGKILLvalue, and bytes drained before the kill are preserved.
For spawn-then-wait control flow, Process.Spawn.spawn(_:) returns a Process.Handle. The handle is ~Copyable and wait() consumes it, so a double wait — which would race on the kernel's already-drained status — is a compile-time error rather than a runtime ECHILD:
import Process
let handle = try Process.Spawn.spawn(
Process.Spawn.Configuration(executable: "/usr/bin/make", arguments: ["-j8"])
)
// ... other work while the child runs ...
let status = try handle.wait() // consumes the handle; a second wait() cannot compileThe bare spawn(_:) path supports inherited streams only; configurations that pipe streams, set a working directory, or set a timeout go through run(_:).
Add swift-process to your Package.swift:
dependencies: [
.package(url: "https://github.com/swift-foundations/swift-process.git", branch: "main")
]Add the product to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "Process", package: "swift-process")
]
)- Swift 6.3+
- macOS 26+, iOS 26+, tvOS 26+, watchOS 26+, visionOS 26+, Linux, Windows
- No Foundation import — composes typed kernel syscall wrappers only; the module never imports Apple's Foundation framework
- Typed throws end-to-end — every throwing entry point throws
Process.Error; noany Errorescapes the API surface - Compiler-enforced single wait —
Process.Handleis~Copyableandwait()isconsuming, making double-wait bugs unrepresentable - Concurrent pipe drain — both-pipes capture on POSIX drains
stdoutandstderrviapoll(2), immune to the pipe-buffer deadlock - Timeout enforcement —
SIGKILLwatchdog on POSIX with partial output preserved - Cross-platform mechanics —
posix_spawn(3)on Darwin and Linux (safe in multithreaded processes; nofork),CreateProcessWwith explicit handle-inheritance lists on Windows
Single module, one namespace. The types a consumer touches:
| Type | Role |
|---|---|
Process.Spawn |
Entry points: spawn(_:) (handle out) and run(_:) (bundled result out) |
Process.Spawn.Configuration |
Executable, arguments, environment, per-stream disposition, working directory, timeout |
Process.Stream |
Stream disposition: .inherit or .pipe |
Process.Handle |
~Copyable reference to a running child; consumed by wait() |
Process.Output |
Exit status plus captured stdout / stderr bytes ([UInt8]?, uninterpreted) |
Process.Status |
.exited(code:), .signaled(signal:), .stopped(signal:) |
Process.Error |
Typed failure surface for spawn, wait, and capture |
Process.exit(_:) |
Immediate process termination (_exit(2) / ExitProcess semantics) |
Captured bytes are returned as [UInt8] without decoding; apply String(decoding:as:) when text is expected.
| Platform | Spawn | Capture | Timeout |
|---|---|---|---|
| macOS / iOS / tvOS / watchOS / visionOS | posix_spawn(3) |
Concurrent drain (both pipes) | SIGKILL watchdog |
| Linux | posix_spawn(3) |
Concurrent drain (both pipes) | SIGKILL watchdog |
| Windows | CreateProcessW |
Sequential drain | Not yet enforced (field accepted, currently a no-op) |
Current scope boundaries, verified as of the latest revision:
stdin: .pipeis not yet supported;run(_:)throws.streamPolicyUnsupportedwhen requested.- On Windows, high-volume dual capture drains sequentially over ~4 KiB anonymous pipes; for large outputs capture one stream at a time.
- Dropping a
Handlewithout waiting leaves the child as a zombie until the parent exits — the standard POSIX trade-off; the package does not silently reap.
All throwing operations throw Process.Error:
Process.Error
├── .invalidPath(index:) // interior NUL byte; index 0 = executable, 1...n = arguments, n+1... = environment
├── .spawn(_) // posix_spawn(3) / CreateProcessW failed (wrapped kernel error)
├── .wait(_) // waitpid(2) / WaitForSingleObject failed (wrapped kernel error)
├── .capture(_) // pipe creation, close, or drain failed (platform error code)
├── .streamPolicyUnsupported // configuration requests a policy this entry point does not support
├── .unrecognizedStatus // kernel returned a status outside the known classifications
└── .platformUnsupported // no subprocess support on this platform
Exhaustive handling:
do throws(Process.Error) {
let output = try Process.Spawn.run(configuration)
// use output
} catch {
switch error {
case .invalidPath(let index):
// reject the offending input (0 = executable, 1...n = arguments)
break
case .spawn(let kernelError), .wait(let kernelError):
// inspect the wrapped POSIX / Win32 error
_ = kernelError
case .capture(let code):
// pipe plumbing failed; code carries the platform error
_ = code
case .streamPolicyUnsupported, .unrecognizedStatus, .platformUnsupported:
// configuration or platform mismatch
break
}
}- swift-posix — POSIX kernel surface this package composes for spawning and waiting.
- swift-windows — Win32 kernel surface backing the Windows path.
- swift-kernel — Cross-platform kernel facade; re-exported by this package for environment access (public, no tagged release yet).
Discussion thread will be created at first public release.
Apache 2.0. See LICENSE for details.