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 には、実際に使ってみて初めて顕在化したいくつかの課題がありました。
run()のオーバーロードが組み合わせ的に増えすぎていました。SF-0007 のExecutionはOutput/Errorについてはジェネリックで、標準出力・標準エラーを条件付きプロパティとして持てましたが、標準入力への書き込みだけは扱いが違い、StandardInputWriterを追加の引数として受け取る専用のrun()オーバーロード群が必要でした。「標準入力に書くかどうか」の組み合わせごとにクロージャ版のオーバーロードが要るため、run()の数が膨れ上がっていました。- 結果型が
CollectedResult(非クロージャ版)とExecutionResult(クロージャ版)の2つに分かれていました。このため「出力をストリームしつつ、同時に別のストリームを collect する」といった使い方ができませんでした。 ExecutionのstandardOutput/standardErrorプロパティは、見た目には何度でもアクセスできるように見えますが、内部のパイプを 1 度だけ消費する性質があり、複数回アクセスすると未定義動作になっていました。- 各クロージャ版
run()にはisolation: isolated (any Actor)? = #isolationを渡す必要がありました。Swift 6.2 でNonisolatedNonsendingByDefaultが使えるようになり、これが不要になります。 - 標準出力と標準エラーを 1 つのストリームに合流させる
2>&1相当の機能は、SF-0007 では Future Directions として残されていました。 - バイト列ストリームから安全に文字列を取り出すには、グラフェムクラスタの境界をまたぐチャンクを自前で扱う必要があり、煩雑でした。
- 環境変数のキーは
Stringでしたが、Windows では大文字小文字を区別せず、その他のプラットフォームでは区別するため、プラットフォームごとの差を吸収する型が望まれていました。 runDetached()は同期的にプロセスを起動するためのエスケープハッチでしたが、PID の再利用に起因する TOCTOU 問題があり、特に Windows では PID がwait()を持たず、プロセス終了直後に再利用され得るため、安全に提供できないことが分かりました。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 として確定させます。主要な変更は次のとおりです。
run() の集約とジェネリックな Execution
3 つの標準ストリームの扱いを揃えるため、Execution を Input についてもジェネリックにします。Execution<Input, Output, Error> の 3 つの型引数が、どのストリーミングプロパティを使えるかを決めます。クロージャは Execution を 1 つだけ受け取り、standardInputWriter / standardOutput / standardError は、対応するストリーム型のときだけ使える型条件付きプロパティとして公開されます。
public struct Execution<
Input: InputProtocol,
Output: OutputProtocol,
Error: OutputProtocol
>: Sendable {
public let processIdentifier: ProcessIdentifier
}
extension Execution where Input == CustomWriteInput {
public var standardInputWriter: StandardInputWriter { get }
}
extension Execution where Output == SequenceOutput {
public var standardOutput: SubprocessOutputSequence { get }
}
extension Execution where Error == SequenceOutput {
public var standardError: SubprocessOutputSequence { get }
}
これを支えるため、これまで内部型だった CustomWriteInput / SequenceOutput と、その .inputWriter / .sequence ファクトリが公開されます。呼び出し側は使いたいストリームだけを個別に opt-in します。
input: .inputWriterでexecution.standardInputWriterが使えます。output: .sequenceでexecution.standardOutputが使えます。error: .sequenceでexecution.standardErrorが使えます。
これにより、標準入力を扱うためだけに専用オーバーロードを選ぶ必要がなくなり、クロージャ版 run() は Executable / Configuration ごとに 1 つの形へ集約されます。出力・標準エラーを読むだけの呼び出し側は、これまでどおり execution.standardOutput を回すだけで、変更は不要です。
// 変更前 (SF-0007): 標準入力に書くには、クロージャが StandardInputWriter を
// 追加で受け取る専用オーバーロードを選ぶ必要があった
let result = try await run(.path("/bin/cat"), output: .sequence) { execution, writer in
_ = try await writer.write("Hello, world")
try await writer.finish()
for try await chunk in execution.standardOutput { ... }
}
// 変更後: 単一のクロージャ形。input: .inputWriter で opt-in し、
// execution.standardInputWriter から writer に触れる
let result = try await run(
.path("/bin/cat"),
input: .inputWriter,
output: .sequence,
error: .discarded
) { execution in
_ = try await execution.standardInputWriter.write("Hello, world")
try await execution.standardInputWriter.finish()
for try await chunk in execution.standardOutput { ... }
}
なお、クロージャ版 run() は input / output / error を明示的に渡す必要があります(デフォルト値なし)。これは、Execution がどのストリーミングプロパティを公開するかをコンパイラが決められるようにするためです。出力を collect するだけの非クロージャ版は、input: .none / error: .discarded といった従来のデフォルトを保ちます。
結果型を ExecutionResult に統合
Execution が Input / Output / Error すべてについてジェネリックになったことで、SF-0007 の 2 つの結果型 CollectedResult<Output, Error> と ExecutionResult<Result> を、1 つのジェネリック型にまとめられます。CollectedResult を廃止し、ExecutionResult に統合します。
public struct ExecutionResult<
ClosureResult: Sendable & ~Copyable,
Output: OutputProtocol,
Error: OutputProtocol
>: Sendable, ~Copyable {
public let processIdentifier: ProcessIdentifier
public let terminationStatus: TerminationStatus
public let standardOutput: Output.OutputType
public let standardError: Error.OutputType
public let closureResult: ClosureResult
}
extension ExecutionResult where ClosureResult: ~Copyable {
public consuming func takeClosureResult() -> ClosureResult
}
extension ExecutionResult: Copyable where ClosureResult: Copyable {}
ClosureResult は、body クロージャを取らない collect 版では Void になり、クロージャ版ではその返り値の型になります。返り値は closureResult プロパティから読めます。統合の副産物として、「出力をストリームしながら、同時に別のストリームを collect する」という、以前は 2 つの結果型に分かれていたためにできなかった使い方も可能になります。
let result = try await run(
.path("/my/app"),
input: .none,
output: .sequence,
error: .string(limit: 4096)
) { execution in
var lineCount = 0
for try await _ in execution.standardOutput.strings() {
lineCount += 1
}
return lineCount
}
print(result.closureResult) // クロージャの返り値(ストリームした出力の行数)
print(result.standardError) // collect した標準エラー(後述のとおり非オプショナルな String)
body クロージャが noncopyable 値を返せる
SF-0007 では body クロージャの返り値は Copyable でなければなりませんでしたが、これを緩和し、non-copyable な値を返せるようにします。クロージャ版 run() の Result 型引数が ~Copyable になります。これに合わせて ExecutionResult 自身も ~Copyable で、ClosureResult が Copyable のときだけ Copyable になります。non-copyable な値は、consuming な takeClosureResult() で結果から取り出します。
struct MoveOnlyResource: ~Copyable { /* ... */ }
let result = try await run(
.path("/usr/bin/my-tool"),
input: .none,
output: .discarded,
error: .discarded
) { execution -> MoveOnlyResource in
return MoveOnlyResource()
}
let resource = result.takeClosureResult()
SubprocessOutputSequence の公開
SF-0007 では standardOutput / standardError の戻り値は不透明な some AsyncSequence でしたが、これを具象の SubprocessOutputSequence に置き換えて公開します。具象型にすることで、後述の行単位の文字列デコードのような高水準ヘルパを出力ストリームに直接付けられます。
public struct SubprocessOutputSequence: 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
}
@available(*, unavailable)
extension SubprocessOutputSequence.Iterator: Sendable {}
SubprocessOutputSequence は内部の OS パイプを所有するため single-pass です。makeAsyncIterator() を 2 回以上呼ぶとトラップします。パイプから読むときのバッファサイズは、プラットフォームのパイプバッファサイズから自動的に決まります。要素の Buffer は、RawSpan を主なアクセサとするイミュータブルなバイト列です。SubprocessFoundation トレイトが有効なときは、Data に Buffer からコピーするイニシャライザが追加されます。
SubprocessOutputSequence.StringSequence による行ストリーム
バイトのチャンクを単純に String 化すると、マルチバイト文字を跨ぐチャンクで壊れる可能性があります。これを安全に扱うため、SubprocessOutputSequence.strings(separatedBy:bufferingPolicy:) で StringSequence を取り出せるようになります。
let monitorResult = try await Subprocess.run(
.path("/usr/bin/tail"),
arguments: ["-f", "/path/to/nginx.log"],
output: .sequence,
error: .discarded
) { execution in
for try await line in execution.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 適合型を渡すオーバーロードもあります。
StringOutput を非オプショナルな String に
SF-0007 では .string(limit:) の出力型 StringOutput の OutputType は String? でした。バイト列を String に変換する際、無効なバイトがあると失敗し得るためです。しかし実際にはほとんどのケースで変換は成功するにもかかわらず、開発者は毎回オプショナルをアンラップするコストを払う必要がありました。そこで OutputType を非オプショナルな String に狭め、内部の変換に String(decoding:as:) を使うようにします。
public struct StringOutput<Encoding: Unicode.Encoding>: OutputProtocol, ErrorOutputProtocol {
public typealias OutputType = String
...
}
String(decoding:as:) は常に成功し、無効なバイトは Unicode の置換文字 (U+FFFD) に置き換えられます。置換文字の有無を調べれば変換の失敗を検出することもできますが、大多数の利用者はアンラップの手間から解放されます。これにより result.standardOutput / result.standardError を直接扱えるようになります。
// 変更前 (SF-0007): standardOutput は String? なので、毎回アンラップが必要だった
let result = try await run(
.path("/bin/echo"),
arguments: ["Hello, world!"],
output: .string(limit: 1024)
)
guard let output = result.standardOutput else { return }
print(output.trimmingCharacters(in: .whitespacesAndNewlines))
// あるいは (result.standardOutput ?? "").trimming... や
// result.standardOutput?.trimming... のような書き方が必要だった。
// 変更後: standardOutput は非オプショナルな String
let result = try await run(
.path("/bin/echo"),
arguments: ["Hello, world!"],
output: .string(limit: 1024)
)
print(result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines))
NonisolatedNonsendingByDefault の採用
upcoming feature flag NonisolatedNonsendingByDefault を採用し、クロージャ版 run() から isolation: isolated (any Actor)? = #isolation パラメータを取り除きます。呼び出し元の actor 上で body が動くという挙動は同じで、API 表面が整理されます。
Environment.Key の導入
環境変数のキーは String ではなく専用の Environment.Key 型になります。Windows では case-insensitive、それ以外では case-sensitive と、プラットフォームごとの慣習をこの型が吸収します。ExpressibleByStringLiteral に適合しているので、リテラルでこれまでと同じように書けます。
extension Environment {
public struct Key: Codable, Hashable, ExpressibleByStringLiteral, Sendable {
public let rawValue: String
}
}
extension Environment.Key: CodingKeyRepresentable, Comparable, RawRepresentable, CustomStringConvertible {}
Environment のメソッドは [String: String] の代わりに [Key: ...] を取るようになります。updating(_:) は値に nil を渡せるようになり、継承した環境変数を子プロセスに渡す前に削除できます。
CombinedErrorOutput と ErrorOutputProtocol
シェルの 2>&1 相当の「標準エラーを標準出力に合流させる」設定が CombinedErrorOutput として導入されます。これは error 専用の出力型なので、OutputProtocol を継承した ErrorOutputProtocol プロトコルも同時に追加されます。ErrorOutputProtocol には新しい要件はなく、「出力にも使える型」と「エラー出力にしか使えない型」を型レベルで区別するためのマーカーです。run() の error: パラメータは ErrorOutputProtocol に制約されます。組み込みの出力型はすべて両方のプロトコルに適合するので、これまでどおりどちらのストリームにも使えます。error 専用なのは CombinedErrorOutput だけです。
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"
FileDescriptorOutput / FileDescriptorInput の拡張
FileDescriptorOutput に、親プロセス自身の標準出力 / 標準エラーへリダイレクトするためのショートカット currentStandardOutput / currentStandardError が追加されます。子プロセスの出力をそのまま親のターミナルへ流したいときに便利です。対称的に、FileDescriptorInput には親プロセスの標準入力を子に渡す currentStandardInput が追加されます。いずれも対象のファイルディスクリプタは閉じられません。
extension OutputProtocol where Self == FileDescriptorOutput {
public static var currentStandardOutput: Self
public static var currentStandardError: Self
}
extension InputProtocol where Self == FileDescriptorInput {
public static var currentStandardInput: Self
}
teardown シーケンスの調整
TeardownStep に 2 つの調整が入ります。1 つ目は .sendSignal(_:allowedDurationToNextStep:) を .send(signal:toProcessGroup:allowedDurationToNextStep:) に改名し、toProcessGroup パラメータを追加して、子プロセスだけでなくプロセスグループ全体をシグナルの対象にできるようにします。2 つ目は .gracefulShutDown(...) にも同じ toProcessGroup を追加します(あわせて綴りミスのラベルも修正)。teardown がプロセスグループを対象にする場合、暗黙の最終ステップ .kill も直前のステップから toProcessGroup を引き継ぐため、子孫プロセスが取り残されません。
PlatformOptions の調整
PlatformOptions は全プラットフォームで Hashable 適合をやめ、Sendable(および CustomStringConvertible / CustomDebugStringConvertible)のみになります。クロージャを持つエスケープハッチ用プロパティに意味のある Hashable 実装はなく、適合が誤解を招いていたためです。Darwin では未使用だった launchRequirementData を削除します。非 Darwin の Unix では、async-signal-safety を安全に保証できない preSpawnProcessConfigurator エスケープハッチを削除します(Darwin と Windows では引き続き提供されます)。Windows では UserCredentials / userCredentials を 1.0 の間 internal 扱いにし、綴りミスの ConsoleBehavior.detatch を .detach に改名します。
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) で公開されます。なお ProcessIdentifier は Darwin では従来どおり pid_t だけを包み、全プラットフォームで Sendable, Hashable になります。プロセスローカルで意味のあるシリアライズができないため、SF-0007 の Codable 適合は削除されます。
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
}
ProcessIdentifier と同様、TerminationStatus も Sendable, Hashable になり、SF-0007 の Codable 適合は削除されます。
Swift 6.1 サポートの終了
Subprocess 1.0 では Swift 6.1 のサポートが打ち切られ、Span を前提とした API に統一されます(swift-tools-version: 6.2 が必要になります)。これに伴い、SubprocessSpan トレイトと、Span が無い環境向けに残されていた OutputProtocol.output(from buffer: some Sequence<UInt8>) 要件は削除され、RawSpan が唯一の currency type になります。あわせて OutputProtocol / InputProtocol は ~Copyable に緩和され、non-copyable な型も適合できるようになります。Swift 6.1 を使い続ける必要がある利用者向けに、0.4 が「Swift 6.1 対応の最終バージョン」としてタグ付けされます。
エラー設計の刷新
SubprocessError まわりが整理されます。
SubprocessError.CodeはHashable & Sendableの構造体になり、spawnFailed、executableNotFound、failedToChangeWorkingDirectory、failedToMonitorProcess、failedToReadFromSubprocess、failedToWriteToSubprocess、outputLimitExceeded、asyncIOFailed、processControlFailedといった意味のある静的プロパティで分類できるようになります。underlyingErrorの型は Unix ではErrno、Windows では新たに導入されるWindowsErrorになります。Windows はエラーを複数のサブシステムで表すため、WindowsErrorはNTSTATUS/ Win32 のDWORD/HRESULT/ C ランタイムのerrnoを表せるenumです。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 {}
}
これに合わせて、エラーハンドリングは「SubprocessError を環境やライブラリ側の問題として捕捉し、body から投げた独自エラーは別の catch 節で扱う」という形が推奨されます。
do {
let result = try await run(...) { execution in
// 開発者は任意のエラーを throw できる
throw MyError()
}
} catch let subprocessError as SubprocessError {
switch subprocessError.code {
case .spawnFailed:
// spawn 失敗
default:
break
}
} catch let myError as MyError {
// body から投げた独自エラー
}
その他の細かな調整
ConfigurationはHashable/Equatableをやめ、Sendable(とCustomStringConvertible/CustomDebugStringConvertible)になります。イニシャライザのラベルはinit(executing:)からinit(executable:)に変わり、workingDirectoryはOptional<FilePath>の stored property になります(nilは親の作業ディレクトリを継承)。Executable.resolveExecutablePath(in:)はasync throws(SubprocessError) -> FilePathになります。パス解決はファイルシステムにアクセスし得るためです。- 出力ファクトリは上限の明示が必須になります。SF-0007 の引数なしの
.string/.bytes/.data(暗黙に 128 KB で打ち切っていた)は、.string(limit:)/.bytes(limit:)/.data(limit:)に置き換わります。既定の.string出力がなくなるため、collect 版run()はoutput:の明示が必要になります。プロセスが上限を超える出力を出すとoutputLimitExceededがthrowされます。 StandardInputWriterの write メソッドは typed throws (throws(SubprocessError)) を採用し、RawSpanを取るオーバーロードは(削除されたSubprocessSpanトレイトのゲートが外れ)無条件で使えるようになります。