Swift Subprocess の導入
Introducing Swift Subprocess
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Swift で外部プロセスを起動するための既存 API である Foundation の Process(旧称 NSTask)は Objective-C 由来で、Swift の進化に追随できていません。async/await をサポートせず、コールバック中心の設計で、開発者のミスは Objective-C 例外で通知されます。スクリプト用途やサーバーサイド開発などでプロセス起動を行うには、API としての使い勝手が悪い状態です。
たとえば、シェルスクリプトであれば次のように git diff の結果を整形して say に渡すだけのコードが、
#!/usr/bin/env bash
changedFiles=$(git diff --name-only)
if [[ -z "$changedFiles" ]]; then
say "No changed files"
else
changedFiles=$(echo "$changedFiles" | tr "\n" ", ")
say "These files have changed: ${changedFiles}"
fi
Swift の Process で書こうとすると、次のように冗長になります。
import Foundation
let gitProcess = Process()
let gitProcessPipe = Pipe()
gitProcess.currentDirectoryURL = URL(fileURLWithPath: ".")
gitProcess.executableURL = URL(fileURLWithPath: "/usr/bin/git")
gitProcess.arguments = ["diff", "--name-only"]
gitProcess.standardOutput = gitProcessPipe
try gitProcess.run()
let processOutput = gitProcessPipe
.fileHandleForReading.readDataToEndOfFile()
gitProcess.waitUntilExit()
var changedFiles = String(data: processOutput, encoding: .utf8)!
// ... say を呼び出すために同じ手続きを繰り返す
このコードには、Process の API にまつわる次のような不便さが現れています。
- 標準入出力にアクセスするには事前に
Pipeを割り当ててstandardOutput等にセットしておく必要があります。standardInput/standardOutput/standardErrorの型はPipeとFileHandleの両方を受け付けるためにAnyになっており、何をどう渡せばよいのかが分かりにくくなっています。 - 実行ファイルは
URLで完全パスを指定する必要があり、$PATHから自動的に解決されません。スクリプト用途では、その都度which相当の処理を自分で書く必要があります。 - 出力を読み取るためには
Pipe経由でFileHandleを取り出す必要があり、どのfileHandleが読み取り側なのかを意識しなければなりません。 async/awaitがサポートされておらず、waitUntilExit()のようなブロッキング呼び出しか、readabilityHandler等のコールバックで非同期性を扱うことになります。複数のプロセスを連携させようとすると、いわゆる「pyramid of doom」になりやすい構造です。
加えて、Process は ABI として既存 SDK に組み込まれているため、互換性を保ったまま大幅に作り変えるのも困難です。async/await ベースで、実行ファイルの解決や標準入出力の扱いも含めて使いやすい、新しいプロセス起動 API が求められていました。
02 どのように解決されるのか
新しいパッケージとして Subprocess が導入されます。Process をいずれ置き換えることを目指した、async/await 前提の新しい API で、Foundation 本体ではなく独立した SwiftPM パッケージとして提供されます。冒頭の例は、Subprocess を使うと次のように書けます。
import Subprocess
let gitResult = try await run(
.name("git"),
arguments: ["diff", "--name-only"]
)
var changedFiles = gitResult.standardOutput!
if changedFiles.isEmpty {
changedFiles = "No changed files"
}
_ = try await run(
.name("say"),
arguments: [changedFiles]
)
Process が Foundation に組み込まれた型であるのに対し、Subprocess はトップレベル関数 run(_:) を中心とした API で、Pipe を自分で作る必要も、URL で実行ファイルのフルパスを書く必要もありません。
パッケージとトレイト
Subprocess パッケージは標準ライブラリと swift-system(FileDescriptor / FilePath)にだけ依存する core モジュールを基本とし、追加機能を SwiftPM の trait で切り替えます。
SubprocessFoundation: デフォルト on。Foundationへの依存を加え、Data関連のオーバーロードを足すSubprocessSpan:Spanが利用可能な環境では on になり、OutputProtocol等の API をRawSpanベースにする
Swift 6.0 以前では実質的に SubprocessFoundation が常に有効で、SubprocessSpan は常に無効として振る舞います。
run() の二系統
プロセスの起動と待機は run() 関数群で行います。大きく次の 2 系統があります。
CollectedResultを返すシンプルな版: プロセスが終了するまで待ち、processIdentifier/terminationStatus/standardOutput/standardErrorをひとまとめにして返します。終了コードや出力さえ得られれば良い、典型的なバッチ用途向けです- クロージャを取る版:
bodyクロージャにExecution値(必要ならStandardInputWriterも)が渡され、その内側でシグナルを送ったり、AsyncSequenceとして標準出力をストリームしたりできます。クロージャの戻り値がExecutionResult<Result>に包まれて返ります
// シンプル版: ls の出力を文字列で受け取る
let ls = try await run(.name("ls"))
print("Items in current directory: \(ls.standardOutput!)")
// シンプル版: cat に標準入力を渡して出力を受け取る
let inputData = Array("Hello SwiftFoundation".utf8)
let cat = try await run(
.name("cat"),
input: .array(inputData),
output: .string
)
print("Cat result: \(cat.standardOutput!)")
// クロージャ版: 出力を JSON としてデコードする
struct MyType: Codable { /* ... */ }
let result = try await run(
.name("curl"),
arguments: ["/some/rest/api"]
) {
var buffer = Data()
for try await chunk in $0.standardOutput {
buffer += chunk
}
return try JSONDecoder().decode(MyType.self, from: buffer)
}
// クロージャ版: 標準入力に書き込みつつ標準出力を読む
let result2 = try await run(
.path("/some/executable")
) { subprocess, writer in
try await writer.write("Hello World".utf8CString)
try await writer.finish()
return try await Array(subprocess.standardOutput)
}
待機を伴わずに起動だけ行う runDetached(_:) も提供されます。親プロセスより長く生きるトランポリン的なプロセス(JVM Launcher のようなもの)を立ち上げるための API で、Subprocess は監視も入出力管理も行わないため、入出力に使う FileDescriptor のライフタイム管理は呼び出し側の責任になります。FileDescriptor を渡さなければ標準入出力は /dev/null に向けられます。
Executable と実行ファイルの解決
実行ファイルは Executable 値として渡します。
.name(_:): 実行ファイル名を指定し、$PATHなどの環境変数からSubprocessがパスを解決する.path(_:): フルパスをそのまま使う
resolveExecutablePath(in:) を使えば、与えた Environment のもとでの解決結果を明示的に取り出すこともできます。
Configuration と Execution
Subprocess は、プロセスの起動条件を表す Configuration と、実行中・実行済みのプロセスを表す Execution の 2 つに役割を分けています。Process 型ひとつにすべての責任が集中していた状況と異なり、Configuration は値型として自由に渡せ、Execution はクロージャ版 run() の body の中だけで有効な参照として扱われます。
public struct Configuration: Sendable, Hashable {
public var executable: Executable
public var arguments: Arguments
public var environment: Environment
public var workingDirectory: FilePath
public var platformOptions: PlatformOptions
}
Execution は対象プロセスの processIdentifier を持ち、SequenceOutput を選んでいる場合は standardOutput / standardError を AsyncSequence<Buffer, any Error> として公開します。これらのストリームは内部のパイプを 1 度だけ消費する性質があり、複数回アクセスしたり redirect 設定なしにアクセスしたりすると fatalError になります。
入力・出力プロトコルと組み込みの実装
入出力の設定は InputProtocol / OutputProtocol に適合した値で表現します。普段使う場面では、用意された次の具象型を選ぶだけで十分です。
入力 (InputProtocol)
.none(NoInput): 入力なし。Unix では/dev/nullに向けられる.fileDescriptor(_:closeAfterSpawningProcess:)(FileDescriptorInput): 任意のFileDescriptorを読み込み元にする。closeAfterSpawningProcessをtrueにすると spawn 直後にSubprocessがクローズする.string(_:using:)(StringInput):StringProtocolの値を指定エンコーディング(既定は UTF-8)で書き込む.array(_:)(ArrayInput):[UInt8]を書き込むCustomWriteInput: クロージャ版run()のStandardInputWriterから書き込むときに使う
SubprocessFoundation トレイトが有効な場合は、.data(_:) / .sequence(_:) 経由で Data / Sequence<Data> / AsyncSequence<Data> も入力として渡せます。
出力 (OutputProtocol)
.discarded(DiscardedOutput): 出力を捨てる。Unix では/dev/nullに向けられる.fileDescriptor(_:closeAfterSpawningProcess:)(FileDescriptorOutput):FileDescriptorに書き出す.string/.string(limit:encoding:)(StringOutput): 文字列として収集する。既定の上限は 128 KiB.bytes/.bytes(limit:)(BytesOutput):[UInt8]として収集する.sequence(SequenceOutput): クロージャ版run()の中で、Execution.standardOutput/standardErrorからAsyncSequenceとしてストリームするSubprocessFoundation有効時は.data/.data(limit:)(DataOutput) でDataとして収集できる
OutputProtocol は SubprocessSpan トレイト下では RawSpan を「主な通貨」として扱い、func output(from span: RawSpan) throws -> OutputType を要件とします。Span が使えない環境では Sequence<UInt8> ベースの要件にフォールバックします。
let ls = try await run(.name("ls"), output: .string)
print("ls output: \(ls.standardOutput!)")
// 上限を 256 KiB に拡げて Data として受け取る
let curl = try await run(
.name("curl"),
output: .data(limit: 256 * 1024)
)
print("curl output bytes: \(curl.standardOutput.count)")
// 任意のファイルディスクリプタへ書き出す
let fd: FileDescriptor = try .open(/* ... */)
let result = try await run(
.path("/some/script"),
output: .fileDescriptor(fd, closeAfterSpawningProcess: true)
)
StandardInputWriter と Buffer
クロージャ版 run() で標準入力への書き込みが必要な場合は、StandardInputWriter がクロージャ引数として渡されます。UInt8 の配列、StringProtocol、RawSpan(SubprocessSpan 有効時)、Data / AsyncSequence<Data>(SubprocessFoundation 有効時)を直接書き込めます。書き終わったら finish() を呼んで標準入力をクローズしてください。
SequenceOutput で標準出力をストリームするときの要素は SequenceOutput.Buffer という独自の immutable な型で、内部表現を共有しつつ withUnsafeBytes(_:) や RawSpan(SubprocessSpan 有効時の bytes プロパティ)でアクセスできます。バイト列をチャンク単位でやり取りすることで、大量出力時のコピーを抑える狙いがあります。
let catResult = try await run(
.path("/some/executable"),
output: .sequence,
error: .discarded
) { execution in
for try await chunk in execution.standardOutput {
let value = String(chunk.bytes, as: UTF8.self)
if value.contains("Done") {
await execution.teardown(using: [
.sendSignal(.quit, allowedDurationToNextStep: .milliseconds(500)),
])
return true
}
}
return false
}
Arguments と Environment
引数は [String] で渡せる Arguments 型として表現されます。ExpressibleByArrayLiteral に適合しているので普段は配列リテラルでよく、Unix 系では実行ファイルパスとは別に「先頭引数(argv[0])」を上書きする executablePathOverride: 付きイニシャライザも提供されます。プロセスの挙動が argv[0] で変わるツール(busybox 系など)に対応するための設計です。Unix では [UInt8] ベースで非 UTF-8 の生バイト引数も渡せますが、Windows の CreateProcessW は文字列しか受け付けないため、Windows では String 版のみ提供されます。
環境変数は Environment 型で表現します。
.inherit: 親プロセスと同じ環境変数を引き継ぐ(既定).inherit.updating(_:): 引き継いだうえで一部だけ上書き.custom(_:): 完全に独自の環境変数
Arguments と同様に、Unix 系では Environment も [UInt8] ベースの生バイトをサポートしますが、Windows では文字列のみです。
TerminationStatus と SubprocessError
プロセス終了は TerminationStatus で表現される enum です。
@frozen
public enum TerminationStatus: Sendable, Hashable, Codable {
public typealias Code = CInt // Windows では DWORD
case exited(Code)
case unhandledException(Code)
public var isSuccess: Bool { get }
}
exited は通常終了の終了コード、unhandledException はシグナルや未処理例外による異常終了を表します。
Subprocess 自身が投げるエラーは SubprocessError 型に統一されます。code プロパティで Subprocess 側の分類を、underlyingError で OS から返ってきた原因を取得できます。Unix 系では underlyingError は errno を包んだ POSIXError、Windows では Windows のエラーコードを包んだ値になります。
シグナル送信と Teardown シーケンス
Unix(macOS / Linux)では、Execution.send(signal:toProcessGroup:) で実行中のプロセスに任意のシグナルを送れます。Signal 型は .interrupt / .terminate / .suspend / .resume / .kill / .quit / .terminalClosed / .userDefinedOne / .userDefinedTwo / .alarm / .windowSizeChange といった名前付き定数を提供しつつ、Signal(rawValue:) で任意の整数値も使えます。
そのうえで、より丁寧な後始末をしたいケース向けに Teardown シーケンスが用意されています。TeardownStep を並べて Execution.teardown(using:) に渡すと、各ステップ間で指定した Duration だけ終了を待ち、終了しなければ次のステップへ進みます。シーケンスの最後では Unix では必ず .kill シグナル、Windows では強制終了が行われます。
let result = try await run(
.path("/bin/bash"),
arguments: [/* ... */]
) { execution in
// ... 任意の処理 ...
await execution.teardown(using: [
.sendSignal(.quit, allowedDurationToNextStep: .milliseconds(100)),
.sendSignal(.terminate, allowedDurationToNextStep: .milliseconds(100)),
])
}
TeardownStep には次の 2 種類があります。
.sendSignal(_:allowedDurationToNextStep:)(Unix のみ): 指定したシグナルを送る.gracefulShutDown(alloweDurationToNextStep:)(クロスプラットフォーム): Unix ではSIGTERM、Windows では GUI プロセスへのWM_CLOSE→ コンソールへのCTRL_C_EVENT→ プロセスグループへのCTRL_BREAK_EVENTの順に試みる
Windows には Unix のような統一的なシグナル機構がないため、シグナル送信そのものではなく、専用の Execution.terminate(withExitCode:) / suspend() / resume() が提供されます。
PlatformOptions とエスケープハッチ
プラットフォーム固有の設定は PlatformOptions にまとまっています。それぞれに、Subprocess が高水準 API を持たない設定をユーザーが直接いじれる「エスケープハッチ」のクロージャ preSpawnProcessConfigurator が用意されています。
- Darwin:
qualityOfService/userID/groupID/supplementaryGroups/processGroupID/createSession/launchRequirementData/teardownSequenceのほか、preSpawnProcessConfiguratorでposix_spawnattr_tとposix_spawn_file_actions_tをposix_spawn()呼び出し直前に書き換えられます - Linux: Darwin と似たユーザー / グループ / セッション系の設定に加え、
closeAllUnknownFileDescriptorsで Darwin のPOSIX_SPAWN_CLOEXEC_DEFAULT相当を opt-in できます。preSpawnProcessConfiguratorは@convention(c) @Sendable () -> Voidで、fork()後exec()前に呼ばれます - Windows:
userCredentials、consoleBehavior(createNew/detatch/inherit)、windowStyle(normal/hidden/maximized/minimized)、createProcessGroupなどを設定できます。preSpawnProcessConfiguratorはCreateProcessWのdwCreationFlagsとSTARTUPINFOWを書き換えるためのクロージャです
// Darwin: posix_spawn のフラグを足す例
var platformOptions = PlatformOptions()
platformOptions.preSpawnProcessConfigurator = { spawnAttr, _ in
let flags: Int32 = POSIX_SPAWN_CLOEXEC_DEFAULT |
POSIX_SPAWN_SETSIGMASK |
POSIX_SPAWN_SETSIGDEF |
POSIX_SPAWN_START_SUSPENDED
posix_spawnattr_setflags(&spawnAttr, Int16(flags))
}
Task キャンセル時の挙動
run() を呼んでいる Task がキャンセルされた場合、Subprocess は確保したリソース(ファイルディスクリプタ等)を解放したうえで、PlatformOptions.teardownSequence に従って子プロセスを終了させます。teardownSequence は最終的に必ず .kill 相当で締めくくられるため、キャンセルされたまま子プロセスが残り続けることは避けられます。
既存コードへの影響
Subprocess はすべて新規 API なので、Process を使っている既存コードへの影響はありません。Subprocess が将来 Process を置き換える役割を担うとしても、当面は両者が共存します。
03 今後の見通し
将来的な拡張として、次の 3 つが構想として挙げられています。
Arguments の自動分割
理想的には "-a -n 1024 -v 'abc'" のような 1 本の文字列を、Arguments 側で自動的に配列へ分割できるようにしたい構想があります。これが実現すれば Arguments を ExpressibleByStringLiteral に適合させ、String でも [String] でも同じように渡せるようになります。ただし、引数の解釈はプラットフォームごとに異なるため正しく実装するのが難しく、最初のバージョンでは見送られています。Python の shlex.split が参考実装の出発点になり得ると述べられています。
stdout と stderr の統合
Python の subprocess のように、標準出力と標準エラー出力を 1 つのストリームに合流させる機能が検討されています。標準エラー出力を不適切に標準出力代わりに使うようなツールを扱うときに有用ですが、既存パラメータと混乱しないかたちで導入する方法を別途設計する必要があります。
プロセス間のパイプ
現在は、ls | grep "swift" のようなパイプ処理を Subprocess で書くと、FileDescriptor.pipe() を作って両端を output / input に手で結線する必要があり、シェルの 1 行に比べて煩雑です。この用途に向けた、より自然な API を改めて設計することが将来の課題として挙げられています。
なお、ここで述べたのは将来の構想であり、その実現を約束するものではありません。実際の追加は別の Proposal として議論されることになります。