Subprocess 1.0 アップデート
Subprocess 1.0 Update
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
SF-0007 で導入された Subprocess は、2025 年春に public beta としてリリースされました。それ以降、コミュニティから多くのフィードバックが寄せられ、API には数多くの調整が加えられてきました。SF-0037 はこれらの変更をまとめ、Subprocess 1.0 として正式にリリースするためのProposalです。
ベータの API には、実際に使ってみて初めて顕在化したいくつかの課題がありました。
ExecutionのstandardOutput/standardErrorプロパティは、見た目には何度でもアクセスできるように見えますが、内部のパイプを 1 度だけ消費する性質があり、複数回アクセスすると未定義動作になっていました。Atomicで防御していたものの、使う側のメンタルモデルとしては正しくありません。CollectedResultという型名は、それが実行結果のレコードであることを表現しきれていません。クロージャ版が返すExecutionResultもSendable適合が漏れていました。- 各
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 の改名と ExecutionResult の Sendable 化
CollectedResult は ExecutionRecord に改名されます。「子プロセスの実行結果として記録されたデータ」であることが名前から伝わり、クロージャ版 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 表面が整理されます。
標準出力 / 標準エラーをクロージャパラメータへ
Execution の standardOutput / 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): 上限超過でSubprocessErrorをthrowします。既定は.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 {}
CombinedErrorOutput と ErrorOutputProtocol
シェルの 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 の HANDLE は UnsafeMutableRawPointer として 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 点が変わります。
- Windows では
.unhandledException(_:)が削除されます。GetExitCodeProcess()はDWORDを 1 つ返すだけで、通常終了の終了コードと未処理例外による終了コードを安全に区別できないためです。 - 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.CodeはHashable & Sendableの構造体になり、spawnFailed、executableNotFound、failedToChangeWorkingDirectory、failedToMonitorProcess、failedToReadFromSubprocess、failedToWriteToSubprocess、outputLimitExceeded、asyncIOFailed、processControlFailedといった意味のある静的プロパティで分類できるようになります。underlyingErrorの型は Unix ではErrno、Windows では新たに導入されるWindowsError(GetLastErrorの値を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 から投げた独自エラー
}