Sanniv Khare 7 min read

From Process to Subprocess 1.0: porting a Mac app’s shell calls

From Process to Subprocess 1.0: porting a Mac app's shell calls

If your Mac app shells out with Foundation.Process, a Pipe and readDataToEndOfFile(), most of those calls can move to one try await run(...) from swift-subprocess 1.0.2 (16 Oct 2026), which needs Swift 6.2 and Xcode 26 or later. Run-and-collect calls and calls with a timeout port cleanly and get shorter. Calls that start something and walk away do not: 1.0 removed runDetached(), so keep those on Process. Before you port, know that in 1.0 .string(limit:) returns a non-optional String, going over the limit throws, and a non-zero exit is a normal result, not an error.

Why we did this: Nix and QuitAll both call system tools (mdfind, pkgutil, codesign, lsregister), and Swift 6.4 (15 Sep 2026) lists “Subprocess reaches 1.0” as a headline. We wanted to know how much of a typical Mac utility’s process code would move, before touching ours.

How we checked

  • We cloned swift-subprocess at tag 1.0.2 (commit dbe21bd) and read the public API in API.swift, Configuration.swift, IO/Output.swift, Teardown.swift and the DocC articles. Every signature below comes from that source.
  • We read the 1.0.0 release notes (4 Aug 2026) and the 1.0.1 and 1.0.2 notes (“bug fix release with no API changes”).
  • We cloned six open-source Mac apps on 12 Nov 2026 (Stats, Maccy, Rectangle, Ice, MonitorControl, Sparkle), found every Swift Process() call site and sorted them by pattern with a small script that looks at the 30 lines after each call. We checked each Stats hit by hand.
  • We did not compile anything. This workspace has no Swift toolchain or Xcode, so the Swift below is marked “not compiled here”.
Terminal summary: 18 Process() call sites in Stats and Ice, sorted by pattern

Stats has 17 call sites and Ice has 1. Maccy, Rectangle and MonitorControl have none. Sparkle uses NSTask from Objective-C.

So, in two real Mac utilities, 11 of 18 calls run a tool and collect its output, 3 only wait for the exit, and 4 launch something and leave. One Stats helper wraps run-and-collect in a hand-made timeout: terminate(), wait 2 seconds, then kill(pid, SIGKILL).

The API mapping

Foundation.Process Subprocess 1.0.2 Source
executableURL = URL(fileURLWithPath: "/usr/bin/top") run(.path("/usr/bin/top"), ...) Configuration.swift
Look up a tool on PATH yourself run(.name("ls"), ...) same
arguments = [...] arguments: [...] or Arguments(array) same
environment = [...] (replaces all) environment: .custom([...]) same
Add one variable to the inherited environment environment: .inherit.updating(["KEY": "value"]) same
currentDirectoryURL workingDirectory: FilePath API.swift
Pipe() + readDataToEndOfFile() + String(data:encoding:) output: .string(limit: N) then result.standardOutput IO/Output.swift
standardError = FileHandle.nullDevice error: .discarded (the default) same
fileHandleForReading.readabilityHandler output: .sequence and execution.standardOutput.strings() in the closure form StreamingAndInput
waitUntilExit() / terminationHandler try await returns when the process has exited API.swift
terminationStatus == 0 result.terminationStatus.isSuccess (.exited(code) or .signaled(code)) Configuration.swift
terminate(), then kill(pid, SIGKILL) cancel the task; platformOptions.teardownSequence runs, and always ends with a kill Teardown.swift
qualityOfService platformOptions.qualityOfService Platforms/Subprocess+Darwin.swift

Porting steps

  1. Add the package. The README still shows .upToNextMinor(from: "0.4.0"), which pins you to 0.4. For 1.0 use .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "1.0.2"). This needs swift-tools-version: 6.2 and Xcode 26 or later.
  2. List your Process() call sites and sort them into the four patterns above. In our sample a 30-line window was enough to tell them apart, with a manual check after.
  3. Port run-and-collect calls first. Pick a limit for each tool that is larger than any output you expect.
  4. Port timeout wrappers to task cancellation plus a teardownSequence.
  5. Port wait-only calls to run(..., output: .discarded) and check terminationStatus.
  6. Leave launch-and-forget calls on Process.
  7. Make the callers async. This is usually the largest change: Stats’ helpers are synchronous functions that return String?.

Run and collect

Before, this is the shape of Stats’ top readers. It reads the pipe before waiting, which is the right order, because a tool that fills the pipe buffer blocks until someone reads it:

let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/top")
task.arguments = ["-l", "1", "-o", "mem", "-n", "\(n)", "-stats", "pid,command,mem"]
let outputPipe = Pipe()
task.standardOutput = outputPipe
try task.run()
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)   // String?

After:

The same call with Subprocess 1.0.2: one run() with .string(limit:)

Subprocess reads the output while it waits, so pipe order is no longer your problem. Not compiled here.

Two things change in behaviour. If top prints more than the limit, run throws a SubprocessError instead of handing you a truncated string. And a non-zero exit does not throw: the source says “A non-zero exit code is a normal result, not a thrown error; inspect terminationStatus instead.”

Timeouts and teardown

Stats’ timeout helper uses a DispatchGroup, two reader queues, terminate(), a 2-second wait and SIGKILL, 69 lines. In Subprocess, cancelling the task that runs the process runs the configured teardown, and Execution.teardown “always ends with a .kill signal”. The default teardownSequence is empty, so without configuration a cancel goes straight to kill. To keep a graceful SIGTERM first:

import Subprocess

func output(of path: String, _ args: [String], timeout: Duration) async throws -> String? {
    var options = PlatformOptions()
    options.teardownSequence = [.gracefulShutDown(allowedDurationToNextStep: .seconds(2))]

    return try await withThrowingTaskGroup(of: String?.self) { group in
        group.addTask {
            try await run(.path(FilePath(path)), arguments: Arguments(args),
                          platformOptions: options,
                          output: .string(limit: 1 << 20)).standardOutput
        }
        group.addTask {
            try await Task.sleep(for: timeout)
            return nil            // timed out
        }
        let first = try await group.next() ?? nil
        group.cancelAll()         // cancels the run: SIGTERM, 2 s, then kill
        return first
    }
}

Not compiled here. The gracefulShutDown step sends SIGTERM on Unix. If you also pass toProcessGroup: true, set createSession in PlatformOptions as well. Otherwise, the source warns, “the targeted process group includes the calling process”.

Streaming output

For long-running tools, such as log stream or a mdfind -live query, use the closure form with .sequence:

_ = try await run(.path("/usr/bin/mdfind"), arguments: ["-live", query],
                  input: .none, output: .sequence, error: .discarded) { execution in
    for try await line in execution.standardOutput.strings() {
        handle(line)
    }
}

strings() splits on line breaks, and by default it caps one line at 128 KiB (.maxLineLength(128 * 1024)). The process must exit before run returns, so cancel the surrounding task to stop it.

What stays on Process

Four of Stats’ calls start something and do not wait: a self-restart (/bin/sh -c "sleep N; open ..."), an updater script, pmset sleepnow and a helper uninstall. In 1.0, run waits for the child to exit before it returns, and the 1.0.0 notes list runDetached() as removed. A self-restart has to outlive the app, so leave these on Process. The same goes for Objective-C code like Sparkle’s 11 NSTask call sites: Subprocess is a Swift API.

What broke between 0.x and 1.0

If you tried Subprocess before August, these are the changes from the 1.0.0 notes that break code:

  • StringOutput.OutputType is now a non-optional String. The README comment still shows Optional("..."). Drop your ?? "".
  • .string, .bytes and .data without a limit are gone. Use .string(limit:), .string(limit:encoding:), .bytes(limit:) and .data(limit:).
  • CollectedResult is gone. Everything returns ExecutionResult, and closureOutput is now closureResult.
  • The closure-based run overloads need explicit input:, output: and error:.
  • runDetached() is removed.
  • TeardownStep.sendSignal is now .send(signal:toProcessGroup:allowedDurationToNextStep:).
  • Configuration(executing:) is now Configuration(executable:).
  • The .standardOutput and .standardError output aliases are removed; use .currentStandardOutput and .currentStandardError.
  • Swift 6.2 is required. For Swift 6.1 the notes say to use the 0.4 tag.

Not tested yet

We have not built this in Xcode 27 or run it in a sandboxed Mac app. The next step is SK’s: port Nix’s mdfind and pkgutil --pkgs calls in a branch, then run them in a sandboxed and a non-sandboxed build on macOS 27.2, with a timeout case. We will add the results here.

Need this built? Let’s talk.

We build and run apps for Mac, TV, mobile and the web, and write about what we learn.