Swift Digest

Swift Subprocess の導入

Introducing Swift Subprocess

Proposal
SF-0007
Authors
Charles Hu
Review Manager
Tina Liu
Status
Accepted as 0.1

このダイジェストは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 の型は PipeFileHandle の両方を受け付けるために 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]
)

ProcessFoundation に組み込まれた型であるのに対し、Subprocess はトップレベル関数 run(_:) を中心とした API で、Pipe を自分で作る必要も、URL で実行ファイルのフルパスを書く必要もありません。

パッケージとトレイト

Subprocess パッケージは標準ライブラリと swift-systemFileDescriptor / 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 のもとでの解決結果を明示的に取り出すこともできます。

ConfigurationExecution

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 / standardErrorAsyncSequence<Buffer, any Error> として公開します。これらのストリームは内部のパイプを 1 度だけ消費する性質があり、複数回アクセスしたり redirect 設定なしにアクセスしたりすると fatalError になります。

入力・出力プロトコルと組み込みの実装

入出力の設定は InputProtocol / OutputProtocol に適合した値で表現します。普段使う場面では、用意された次の具象型を選ぶだけで十分です。

入力 (InputProtocol)

  • .none (NoInput): 入力なし。Unix では /dev/null に向けられる
  • .fileDescriptor(_:closeAfterSpawningProcess:) (FileDescriptorInput): 任意の FileDescriptor を読み込み元にする。closeAfterSpawningProcesstrue にすると 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 として収集できる

OutputProtocolSubprocessSpan トレイト下では 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)
)

StandardInputWriterBuffer

クロージャ版 run() で標準入力への書き込みが必要な場合は、StandardInputWriter がクロージャ引数として渡されます。UInt8 の配列、StringProtocolRawSpanSubprocessSpan 有効時)、Data / AsyncSequence<Data>SubprocessFoundation 有効時)を直接書き込めます。書き終わったら finish() を呼んで標準入力をクローズしてください。

SequenceOutput で標準出力をストリームするときの要素は SequenceOutput.Buffer という独自の immutable な型で、内部表現を共有しつつ withUnsafeBytes(_:)RawSpanSubprocessSpan 有効時の 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
}

ArgumentsEnvironment

引数は [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 では文字列のみです。

TerminationStatusSubprocessError

プロセス終了は 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 系では underlyingErrorerrno を包んだ 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 のほか、preSpawnProcessConfiguratorposix_spawnattr_tposix_spawn_file_actions_tposix_spawn() 呼び出し直前に書き換えられます
  • Linux: Darwin と似たユーザー / グループ / セッション系の設定に加え、closeAllUnknownFileDescriptors で Darwin の POSIX_SPAWN_CLOEXEC_DEFAULT 相当を opt-in できます。preSpawnProcessConfigurator@convention(c) @Sendable () -> Void で、fork()exec() 前に呼ばれます
  • Windows: userCredentialsconsoleBehaviorcreateNew / detatch / inherit)、windowStylenormal / hidden / maximized / minimized)、createProcessGroup などを設定できます。preSpawnProcessConfiguratorCreateProcessWdwCreationFlagsSTARTUPINFOW を書き換えるためのクロージャです
// 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 側で自動的に配列へ分割できるようにしたい構想があります。これが実現すれば ArgumentsExpressibleByStringLiteral に適合させ、String でも [String] でも同じように渡せるようになります。ただし、引数の解釈はプラットフォームごとに異なるため正しく実装するのが難しく、最初のバージョンでは見送られています。Python の shlex.split が参考実装の出発点になり得ると述べられています。

stdoutstderr の統合

Python の subprocess のように、標準出力と標準エラー出力を 1 つのストリームに合流させる機能が検討されています。標準エラー出力を不適切に標準出力代わりに使うようなツールを扱うときに有用ですが、既存パラメータと混乱しないかたちで導入する方法を別途設計する必要があります。

プロセス間のパイプ

現在は、ls | grep "swift" のようなパイプ処理を Subprocess で書くと、FileDescriptor.pipe() を作って両端を output / input に手で結線する必要があり、シェルの 1 行に比べて煩雑です。この用途に向けた、より自然な API を改めて設計することが将来の課題として挙げられています。

なお、ここで述べたのは将来の構想であり、その実現を約束するものではありません。実際の追加は別の Proposal として議論されることになります。