| summary |
read_when |
| Review swift-subprocess Adoption Guide guidance |
| planning work related to swift-subprocess adoption guide |
| debugging or extending features described here |
|
swift-subprocess Adoption Guide
Why We Care
- Our test suites launch dozens of child processes (
swift run peekaboo, axorc, shell utilities) and each file hand-rolls Process, Pipe, and blocking drain logic. This duplication is fragile and contributes to flakiness when stdout/stderr buffers fill.
- The
swift-subprocess package (latest tag 0.2.1, Swift 6.1+/macOS 13+) ships an async/await-native wrapper around posix_spawn, providing streaming output via AsyncSequence, structured configuration, and built-in cancellation. It eliminates the classic deadlock that occurs when Process pipes aren’t drained quickly enough.citeturn1open0turn1open1turn1open2
- Package status: beta, owned by the Swift project, with the first stable release targeted for early 2026. Expect API polishing; keep adoption behind our own façade so we can react to breaking changes quickly.citeturn1open0turn1open1
Pilot Scope (Tests First)
- Focus the first integration on the now-retired CLI runner (
Apps/CLI/Tests/CLIAutomationTests/Support/CommandRunner.swift). All “safe” suites run via InProcessCommandRunner, and historical references to PeekabooCLITestRunner have been removed.
- Audit additional hot spots once the pilot lands:
AXorcist test helpers (AXorcist/Tests/AXorcistTests/CommonTestHelpers.swift) when invoking the axorc binary.
- CLI automation tests that manually stand up
Process instances for menu/window focus helpers (Apps/CLI/Tests/CLIAutomationTests/*.swift, see rg "Pipe()" output). These can eventually share a common helper that wraps Subprocess.
- Production code paths (e.g.
ShellTool, DockService) remain untouched until the test pilot proves stable and we design a broader façade for long-lived services.
Integration Plan
- Add the dependency
- Declare
swift-subprocess in the relevant package manifests: start with Apps/CLI/Package.swift and AXorcist/Package.swift test targets only. Keep it test-only until we validate behavior.
- Pin to an explicit minor version (
from: "0.2.1") and enable exact revisions in Package.resolved to avoid silent API shifts.
- Wrap Subprocess behind a helper
- Introduce a small internal type (e.g.
TestChildProcess) that mirrors the subset of features we rely on (arguments, environment, streaming stdout/stderr, timeout). This wrapper will call into Subprocess’ ChildProcess.spawn(...), surface async iteration of process.stdout.lines, and return collected output on success/failure.
- Preserve our existing error surface (
CommandError(status:output:)) by translating SubprocessError into our domain model. Include the captured stderr text in thrown errors.
- Retire
PeekabooCLITestRunner
- Historical note: the runner has been removed now that every automation suite runs via the harness.
- Roll out to other helpers
- Migrate AXorcist’s
runAXORCCommand and similar utilities once the CLI pilot is stable for a week of CI runs.
- Document any platform-specific observations (e.g. sandbox quirks, resource cleanup) in this file as we go.
- Evaluate production adoption
- After tests prove reliable, design a PeekabooCore abstraction (
ChildProcessService) that can swap Process vs. Subprocess internally. Production code often needs cancellation, long-running streaming, and the occasional pseudo-terminal; confirm Subprocess’ PTY story before we rely on it inside the MCP transports.
Usage Cheatsheet
import Subprocess
struct TestChildProcess {
static func runPeekaboo(_ args: [String]) async throws -> String {
var options = ChildProcessOptions()
options.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
options.environment = ProcessInfo.processInfo.environment
let process = try await ChildProcess.spawn(
command: "/usr/bin/env",
arguments: ["swift", "run", "peekaboo"] + args,
options: options
)
var output = ""
for try await line in process.stdout.lines {
output.append(line)
output.append("\n")
}
let exitStatus = await process.waitForExit()
guard exitStatus == .code(0) else {
throw CommandError(status: exitStatus.exitCode, output: output)
}
return output
}
}
ChildProcess.spawn returns immediately; consumers iterate its AsyncThrowingStream properties (stdout.bytes, stdout.lines, stderr.lines) without extra pipes or threads.
waitForExit() yields a ChildProcess.Termination enum. Use .code(Int32) for numeric exit codes, .signal(Int32) for signal terminations.
- Cancellation: wrapping the spawn in
withTimeout or explicitly calling process.terminate() cooperates with async tasks. This will help us enforce per-test timeouts instead of blocking on waitUntilExit().
Open Questions
- PTY support is currently experimental. Even though our MCP server relies on stdio pipes, confirm Subprocess’ pseudo-terminal roadmap before depending on it for future CLI integrations.
- Some of our tests rely on combined stdout/stderr ordering. Subprocess exposes them separately; we need to decide whether to merge streams manually or only capture stderr when non-empty.
- Monitor the upstream issue tracker for breaking changes ahead of
1.0.0; update this doc with any migration notes after each dependency bump.