Swift Digest

Subprocess 1.0 アップデート

Subprocess 1.0 Update

Proposal
SF-0037
Authors
Charles Hu
Review Manager
Tina Liu
Status
Post Review Update. Review: 2026-04-13...2026-04-20

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

SF-0007 で導入された Subprocess は、2025 年春に public beta としてリリースされました。それ以降、コミュニティから多くのフィードバックが寄せられ、API には数多くの調整が加えられてきました。SF-0037 はこれらの変更をまとめ、Subprocess 1.0 として正式にリリースするためのProposalです。

ベータの API には、実際に使ってみて初めて顕在化したいくつかの課題がありました。

  • ExecutionstandardOutput / standardError プロパティは、見た目には何度でもアクセスできるように見えますが、内部のパイプを 1 度だけ消費する性質があり、複数回アクセスすると未定義動作になっていました。Atomic で防御していたものの、使う側のメンタルモデルとしては正しくありません。
  • CollectedResult という型名は、それが実行結果のレコードであることを表現しきれていません。クロージャ版が返す ExecutionResultSendable 適合が漏れていました。
  • run() には isolation: isolated (any Actor)? = #isolation を渡す必要がありました。Swift 6.2 で NonisolatedNonsendingByDefault が使えるようになり、これが不要になります。
  • runDetached() は同期的にプロセスを起動するためのエスケープハッチでしたが、PID の再利用に起因する TOCTOU 問題があり、特に Windows では PID が wait() を持たず、プロセス終了直後に再利用され得るため、安全に提供できないことが分かりました。
  • 標準出力と標準エラーを 1 つのストリームに合流させる 2>&1 相当の機能は、SF-0007 では Future Directions として残されていました。
  • バイト列ストリームから安全に文字列を取り出すには、グラフェムクラスタの境界をまたぐチャンクを自前で扱う必要があり、煩雑でした。
  • 環境変数のキーは String でしたが、Windows では大文字小文字を区別せず、その他のプラットフォームでは区別するため、プラットフォームごとの差を吸収する型が望まれていました。
  • TerminationStatus.exited(_:).unhandledException(_:) の 2 ケースを持っていました。.unhandledException は Unix の wait(2) のビットフィールドに合わせた名前ですが、実体はシグナルによる終了であり、また Windows の GetExitCodeProcess() は通常終了とそれ以外を区別できないため、不整合がありました。
  • SubprocessError.Code は素の Int で、開発者は数値の意味を覚える必要がありました。エラーをどう投げ、どう catch すべきかの方針も明示されていませんでした。
  • ベータ期は Swift 6.1 もサポートするために Span が使えない環境向けの shim が API に残っていました。1.0 でこれを整理する余地がありました。

これらをまとめて整理し、Subprocess 1.0 として安定した API を確定させる必要がありました。

02 どのように解決されるのか

SF-0007 から積み上がった変更を 1.0 の API として確定させます。主要な変更は次のとおりです。

CollectedResult の改名と ExecutionResultSendable

CollectedResultExecutionRecord に改名されます。「子プロセスの実行結果として記録されたデータ」であることが名前から伝わり、クロージャ版 run() の返り値である ExecutionResult との対比も自然になります。あわせて、ExecutionResult には Sendable 適合が追加されます。ラップする value 自体が Sendable 必須なので、外側も問題なく Sendable にできます。

NonisolatedNonsendingByDefault の採用

upcoming feature flag NonisolatedNonsendingByDefault を採用し、各 run() から isolation: isolated (any Actor)? = #isolation パラメータを取り除きます。

// 変更前
public func run<...>(
    ...,
    isolation: isolated (any Actor)? = #isolation,
    body: (_ execution: Execution) async throws -> Result
) async throws -> ExecutionOutcome<Result> where Error.OutputType == Void

// 変更後
public func run<...>(
    ...,
    body: (_ execution: Execution) async throws -> Result
) async throws -> ExecutionOutcome<Result> where Error.OutputType == Void

呼び出し元の actor 上で body が動くという挙動は同じで、API 表面が整理されます。

標準出力 / 標準エラーをクロージャパラメータへ

ExecutionstandardOutput / standardError プロパティは廃止され、クロージャ版 run()body の引数として並列に渡されるようになります。これにより「複数回アクセスしてしまう」事故が型レベルで防がれ、Execution も非ジェネリックでシンプルな型になります。

// 変更前
let result = try await run(...) { execution in
    for try await item in execution.standardOutput { ... }
}

// 変更後
let result = try await run(...) { execution, standardInput, standardOutput, standardError in
    for await item in standardOutput { ... }
}

クロージャの引数構成は、StandardInputWriter を取るかどうか、標準出力 / 標準エラーをストリームするかどうかの組み合わせで複数のオーバーロードに分かれます。たとえば標準出力だけをストリームしたい場合は次のような形になります。

public func run<Result, Input: InputProtocol, Error: ErrorOutputProtocol>(
    _ executable: Executable,
    arguments: Arguments = [],
    environment: Environment = .inherit,
    workingDirectory: FilePath? = nil,
    platformOptions: PlatformOptions = PlatformOptions(),
    input: Input = .none,
    error: Error = .discarded,
    preferredBufferSize: Int? = nil,
    body: (
        _ execution: Execution,
        _ outputSequence: AsyncBufferSequence
    ) async throws -> Result
) async throws -> ExecutionResult<Result> where Error.OutputType == Void

AsyncBufferSequence の公開

ストリーム型を引数で渡す都合上、some AsyncSequence のままでは API として機能しません。これまで内部に隠していた AsyncBufferSequence を具象型として公開します。要素は Buffer で、イテレータは Sendable ではありません(@available(*, unavailable) で明示)。

public struct AsyncBufferSequence: AsyncSequence, Sendable {
    public typealias Failure = any Swift.Error
    public typealias Element = Buffer

    public struct Iterator: AsyncIteratorProtocol {
        public typealias Element = Buffer
        public mutating func next() async throws -> Buffer?
    }

    public func makeAsyncIterator() -> Iterator
}

preferredBufferSize の追加

標準出力 / 標準エラーをストリームする run() オーバーロードには、preferredBufferSize: Int? パラメータが追加されます。既定ではプラットフォームのページサイズが使われます。子プロセスの出力が疎な場合、ページサイズ分のバッファが埋まるまで読み出しがブロックして「進まない」ように見えることがあるため、用途に合わせて調整できるようにするのが目的です。

AsyncBufferSequence.StringSequence による行ストリーム

バイトのチャンクを単純に String(decoding:) で文字列化すると、グラフェムクラスタを跨ぐチャンクで壊れる可能性があります。これを安全に扱うため、AsyncBufferSequence.strings(separatedBy:bufferingPolicy:as:)StringSequence を取り出せるようになります。

async let monitorResult = try await Subprocess.run(
    .path("/usr/bin/tail"),
    arguments: ["-f", "/path/to/nginx.log"]
) { execution, standardOutput in
    for try await line in standardOutput.strings() {
        if line.contains("500") {
            // 500 エラーを処理
        }
    }
}

Separator で区切り方を選べます。

  • .lineBreaks(既定): Unicode の改行文字 (LF, VT, FF, CR, CR+LF, NEL, LS, PS) で分割します。
  • .unicodeScalarSequence(_:): 任意の Unicode scalar 列で分割します。バッファ上ではバイト単位の比較になるため、String のような正規化は行われません。

BufferingPolicy で 1 行のバッファ上限を制御します。

  • .unbounded: 上限なし。
  • .maxLineLength(Int): 上限超過で SubprocessErrorthrow します。既定は .maxLineLength(128 * 1024)

エンコーディングは UTF-8 が既定で、as: UTF16.self のように _UnicodeEncoding 適合型を渡すこともできます。

Environment.Key の導入

環境変数のキーは String ではなく専用の Environment.Key 型になります。Windows では case-insensitive、それ以外では case-sensitive と、プラットフォームごとの慣習をこの型が吸収します。ExpressibleByStringLiteral に適合しているので、リテラルでこれまでと同じように書けます。

extension Environment {
    public struct Key: Codable, Hashable, ExpressibleByStringLiteral, Sendable {
        public var rawValue: String
    }
}

extension Environment.Key: CodingKeyRepresentable, Comparable, RawRepresentable, CustomStringConvertible {}

CombinedErrorOutputErrorOutputProtocol

シェルの 2>&1 相当の「標準エラーを標準出力に合流させる」設定が CombinedErrorOutput として導入されます。これは error 専用の出力型なので、OutputProtocol を継承した ErrorOutputProtocol プロトコルも同時に追加されます。ErrorOutputProtocol には新しい要件はなく、出力に使える型と「エラー出力にしか使えない型」を型レベルで区別するためのマーカーです。

public protocol ErrorOutputProtocol: OutputProtocol {}

public struct CombinedErrorOutput: ErrorOutputProtocol {
    public typealias OutputType = Void
}

extension ErrorOutputProtocol where Self == CombinedErrorOutput {
    public static var combinedWithOutput: Self
}

使い方は error: .combinedWithOutput を渡すだけです。

let result = try await run(
    .path("/bin/sh"),
    arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"],
    output: .string(limit: 1024),
    error: .combinedWithOutput
)
// result.standardOutput は "Hello Stdout;\nHello Stderr"

runDetached の削除

runDetached() は削除されます。PID の再利用に起因する TOCTOU 問題があり、特に Windows では wait() 相当の仕組みが PID には用意されていないため、runDetached() が PID を返した時点で別プロセスに割り当てられている可能性があります。複雑な回避策を入れるよりも、Subprocess の中核機能ではなかったこの API を削除する方針が選ばれました。

Windows / Linux 向けの ProcessIdentifier 拡張

PID 再利用問題に対する別の対処として、ProcessIdentifier にプラットフォーム固有のプロセスファイルディスクリプタを追加します。Linux / Android / FreeBSD では pidfd_open() 由来の processDescriptor: CInt、Windows では HANDLE 型の processDescriptor および threadHandle が公開されます。

// Linux / Android / FreeBSD
public struct ProcessIdentifier: Sendable, Hashable {
    public let value: pid_t

    #if os(Linux) || os(Android) || os(FreeBSD)
    public let processDescriptor: CInt
    #endif
}

// Windows
public struct ProcessIdentifier: Sendable, Hashable {
    public let value: DWORD
    public nonisolated(unsafe) let processDescriptor: HANDLE
    public nonisolated(unsafe) let threadHandle: HANDLE
}

Linux のドキュメントが述べるように、pidfd_open() で得たディスクリプタは、対象プロセスが既に終了していても PID が再利用されることなくそのプロセス(zombie)を指し続けます。生の PID よりも、こちらを参照する方が安全です。

Windows の HANDLEUnsafeMutableRawPointer として import されるため Sendable ではありません。実体はカーネルオブジェクトの不透明な識別子で、ユーザー空間でデリファレンスされず、整数値のコピーと等価に扱える immutable な値です。これに合わせ nonisolated(unsafe) で公開されます。

FileDescriptorOutput の拡張

FileDescriptorOutput に、親プロセス自身の標準出力 / 標準エラーへリダイレクトするためのショートカット currentStandardOutput / currentStandardError が追加されます。子プロセスの出力をそのまま親のターミナルへ流したいときに、自分でファイルディスクリプタを開く必要がなくなります。これらを使ったときに対象のファイルディスクリプタは閉じられません。

extension OutputProtocol where Self == FileDescriptorOutput {
    public static var currentStandardOutput: Self
    public static var currentStandardError: Self
}

Windows 向け TerminationStatus の再設計

TerminationStatus は次の 2 点が変わります。

  1. Windows では .unhandledException(_:) が削除されます。GetExitCodeProcess()DWORD を 1 つ返すだけで、通常終了の終了コードと未処理例外による終了コードを安全に区別できないためです。
  2. Unix では .unhandledException(_:).signaled(_:) に改名されます。実体はシグナルによる終了であり、例外ハンドリングの仕組みではありません。
public enum TerminationStatus: Sendable, Hashable {
    #if os(Windows)
    public typealias Code = DWORD
    #else
    public typealias Code = CInt
    #endif

    case exited(Code)

    #if !os(Windows)
    case signaled(Code)
    #endif

    public var isSuccess: Bool
}

Swift 6.1 サポートの終了

Subprocess 1.0 では Swift 6.1 のサポートが打ち切られ、Span を前提とした API に統一されます。これに伴い、SubprocessSpan トレイトと、Span が無い環境向けに残されていた OutputProtocol.output(from buffer: some Sequence<UInt8>) 要件は削除されます。Swift 6.1 を使い続ける必要がある利用者向けに、Subprocess の「Swift 6.1 対応の最終バージョン」がタグ付きで残されます。

エラー設計の刷新

SubprocessError まわりが整理されます。

  • SubprocessError.CodeHashable & Sendable の構造体になり、spawnFailedexecutableNotFoundfailedToChangeWorkingDirectoryfailedToMonitorProcessfailedToReadFromSubprocessfailedToWriteToSubprocessoutputLimitExceededasyncIOFailedprocessControlFailed といった意味のある静的プロパティで分類できるようになります。
  • underlyingError の型は Unix では Errno、Windows では新たに導入される WindowsErrorGetLastError の値を DWORD で持つ RawRepresentable)になります。
  • Subprocess 内部は typed throws で書かれ、Subprocess 自身は SubprocessError のみを throw します。例外として、body クロージャや preSpawnProcessConfigurator から開発者が任意のエラーを throw できる点は残ります。
public struct SubprocessError: Swift.Error, Sendable, Hashable {
    #if os(Windows)
    public typealias UnderlyingError = WindowsError
    #else
    public typealias UnderlyingError = Errno
    #endif

    public let code: SubprocessError.Code
    public let underlyingError: UnderlyingError?
}

extension SubprocessError {
    public struct Code: Hashable, Sendable {}
}

extension SubprocessError.Code {
    public static var spawnFailed: Self
    public static var executableNotFound: Self
    public static var failedToChangeWorkingDirectory: Self
    public static var failedToMonitorProcess: Self
    public static var failedToReadFromSubprocess: Self
    public static var failedToWriteToSubprocess: Self
    public static var outputLimitExceeded: Self
    public static var asyncIOFailed: Self
    public static var processControlFailed: Self
}

これに合わせて、エラーハンドリングは「SubprocessError を環境やライブラリ側の問題として捕捉し、body から投げた独自エラーは別の catch 節で扱う」という形が推奨されます。

do {
    let result = try await run(...) { execution, standardInput, standardOutput, standardError in
        // 開発者は任意のエラーを throw できる
        throw MyError()
    }
} catch let subprocessError as SubprocessError {
    switch subprocessError.code {
    case .spawnFailed:
        // spawn 失敗
    default:
        break
    }
} catch let myError as MyError {
    // body から投げた独自エラー
}