概要
- Swift Concurrencyを使ってProcessでコマンドを実行し、キャンセルや並列に実行できるように実装してみました。

- 呼び出し例としてはコマンドをバックグラウンドで実行させ、キャンセルボタンから処理を中断できるようにしたり…
@State private var task: Task<(), Never>?
@State private var isProcessing = false
private func handleButtonClicked(command: String) {
isProcessing = true
task = Task {
defer {
isProcessing = false
}
do {
let output = try await Command.execute(command: command)
print(output)
} catch {
print(error.localizedDescription)
return
}
}
}
// キャンセルボタン
Button("Cancel") {
isProcessing = false
task?.cancel()
task = nil
}
-
xcodebuild
を並列に実行することができます。
// xcodebuildを並列に呼び出す例
private func handleXcodeBuildButtonClicked() {
isProcessing = true
task = Task {
defer {
isProcessing = false
}
do {
try await withThrowingTaskGroup(of: String.self) { group in
for program in Program.sampleData {
group.addTask {
return try await Command.execute(command: program.commandForArchive)
}
}
for try await output in group {
_ = output
print(output)
}
}
} catch {
print(error.localizedDescription)
return
}
}
}
- 実行結果の例は以下の通りで、標準出力で結果を取得することができます。
# echo実行
/Users/ikeh/Desktop
# ping実行 -> キャンセル時
処理をキャンセルしました。
標準出力: PING google.co.jp (142.250.76.131): 56 data bytes
64 bytes from 142.250.76.131: icmp_seq=0 ttl=115 time=18.389 ms
64 bytes from 142.250.76.131: icmp_seq=1 ttl=115 time=20.464 ms
64 bytes from 142.250.76.131: icmp_seq=2 ttl=115 time=22.120 ms
# xcodebuild実行
2023-06-30 17:20:43.787184+0900 xcodebuild[16763:7846046] DVTCoreDeviceEnabledState: DVTCoreDeviceEnabledState_Disabled set via user default (DVTEnableCoreDevice=disabled)
(略)
** ARCHIVE SUCCEEDED **
2023-06-30 17:20:48.079487+0900 xcodebuild[16762:7846271] [client] No error handler for XPC error: Connection invalid
# lsの実行
total 47432
-rw-r--r--@ 1 ikeh staff 964 Sep 20 2022 CertificateSigningRequest.certSigningRequest
com.apple.TextEncoding 15
com.apple.macl 72
(略)
参考
- 偏見と妄想で語るスクリプト言語としての Swift / Swift as a Scripting Language
- 「偏見と妄想で語るスクリプト言語としての Swift」登壇補足
- Swift Concurrency チートシート
- AWS S3(2)S3Sync:S3バックアップツール
- SwiftからPOSIXコマンドを呼び出す
GitHub
実装
コマンドを非同期に実行するメソッド(コード全体)
- 全体のコードとしては以下の通り。
- 詳細は以降に記載します。
struct Command {
@discardableResult
static func execute(command: String, currentDirectoryURL: URL? = nil) async throws -> String {
let process = Process()
process.launchPath = "/bin/zsh"
process.arguments = ["-cl", command]
process.currentDirectoryURL = currentDirectoryURL
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
throw CommandError.failedInRunning
}
var standardOutput = ""
// Processが完了するまで、Taskがキャンセルされていないかを監視
while process.isRunning {
do {
try Task.checkCancellation()
} catch {
process.terminate()
throw CommandError.cancel(standardOutput) // キャンセル途中までのの標準出力を返す
}
// readToEnd()ではpingなどのキャンセル時に途中経過が取得できないのでavailableDataを使用
let data = pipe.fileHandleForReading.availableData
if data.count > 0,
let _standardOutput = String(data: data, encoding: .utf8) {
standardOutput += _standardOutput
}
try? await Task.sleep(nanoseconds: 0_500_000_000) // wait 0.5s
}
// 残りの標準出力の取得
if let _data = try? pipe.fileHandleForReading.readToEnd(),
let _standardOutput = String(data: _data, encoding: .utf8) {
standardOutput += _standardOutput
}
try? await Task.sleep(nanoseconds: 0_500_000_000) // wait 0.5s
if process.terminationStatus != 0 {
throw CommandError.exitStatusIsInvalid(process.terminationStatus, standardOutput)
}
return standardOutput
}
}
コマンド実行用のエラー定義
- コマンド実行時のエラーハンドリングのため、以下の通りカスタムのErrorを定義します。
-
cancel
時にはそれまでの標準出力、またexitStatusIsInvalid
では終了ステータスと標準出力を返すようにしています。
-
- また
LocalizedError
に準拠させることで、エラーの詳細をerror.localizedDescription
で取得できるようにしています。
enum CommandError: Error {
case cancel(String) // Taskがキャンセルされた
case failedInRunning // process.run()でエラーが発生
case exitStatusIsInvalid(Int32, String) // 終了ステータスが0以外
}
extension CommandError: LocalizedError {
// error.localizedDescriptionで表示される内容
var errorDescription: String? {
switch self {
case .cancel(let output):
return "処理をキャンセルしました。\n標準出力: \(output)"
case .failedInRunning:
return "コマンドの実行時にエラーが発生しました"
case .exitStatusIsInvalid(let status, let output):
return
"""
コマンドの実行が正常に完了しませんでした。\n\
終了コード: \(status)\n\
標準出力: \(output)
"""
}
}
}
static関数での定義
- staticメソッドにしている理由としては、Processオブジェクトは一回きりしか使用できずインスタンスを利用する必要がないためです。
Process
You can only run a Process object once. Subsequent attempts raise an error.
- 返り値としては
String
の標準出力を返すようにしており、また実行中にエラーが発生した際はエラーを投げるようにしています。
@discardableResult
static func execute(command: String, currentDirectoryURL: URL? = nil) async throws -> String {
...
}
- 引数の
currentDirectoryURL
に関しては、コマンドの実行時のパスを指定できます。 - 例えば以下のようにDesktop上の
ls
の実行結果を取得することができます。
try await Command.execute(command: "ls -l@", currentDirectoryURL: URL(fileURLWithPath: "/Users/ikeh/Desktop/"))
Processの設定と実行
- Processの初期設定と実行部分の実装です。
-
NSTask
の慣習からlet task = Process()
と変数名をつける例も多いのですが、Swift ConcurrencyのTask
とややこしいのでprocess
としています。
-
- launch()はDeprecatedなのでrun()を使用します。
let process = Process()
process.launchPath = "/bin/zsh"
process.arguments = ["-cl", command]
process.currentDirectoryURL = currentDirectoryURL
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
throw CommandError.failedInRunning
}
Processのキャンセル受付と標準出力の取得
- 処理が完了するまでの間、関数の外からキャンセルできるように、
while process.isRunning
の中で、try Task.checkCancellation()
を定期的に呼び出しています。- 参考: 💼 Case 12 (Task.checkCancellation()): 非同期処理のキャンセル(非
同期 API の実装側①) - もしキャンセルが呼ばれている場合、プロセスを
terminate
で停止してからエラーを投げます。
- 参考: 💼 Case 12 (Task.checkCancellation()): 非同期処理のキャンセル(非
- キャンセルのチェック後、毎回
pipe.fileHandleForReading.availableData
で標準出力を吸い出すようにしています。- このとき
readToEnd()
ではpingなどのキャンセル時に途中経過が取得できないのでavailableData
を使用しています。
- このとき
- またreadDataToEndOfFile()はDeprecatedのため、readToEnd()を使用します。
var standardOutput = ""
// Processが完了するまで、Taskがキャンセルされていないかを監視
while process.isRunning {
do {
try Task.checkCancellation()
} catch {
process.terminate()
throw CommandError.cancel(standardOutput) // キャンセル途中までのの標準出力を返す
}
// readToEnd()ではpingなどのキャンセル時に途中経過が取得できないのでavailableDataを使用
let data = pipe.fileHandleForReading.availableData
if data.count > 0,
let _standardOutput = String(data: data, encoding: .utf8) {
standardOutput += _standardOutput
}
try? await Task.sleep(nanoseconds: 0_500_000_000) // wait 0.5s
}
// 残りの標準出力の取得
if let _data = try? pipe.fileHandleForReading.readToEnd(),
let _standardOutput = String(data: _data, encoding: .utf8) {
standardOutput += _standardOutput
}
- 余談ながら当初下記の通り書いていましたが、
xcodebuild
など標準出力に多くのデータが渡される場合にprocess.isRunning
がfalse
にならず処理が止まってしまう問題がありました。 - これは
Pipe
のバッファの制限があり、標準出力に書き込もうとしているができずに処理が止まっているようでした。- そのため上記の通り都度標準出力を吸い出すような処理としています。
- 参考: Swift Developers JapanのDiscordでの質問
Objective-C, NSTask Buffer Limitation
The NSPipe buffer limit seems to be 4096 bytes
while process.isRunning {
do {
try Task.checkCancellation()
} catch {
process.terminate()
throw CommandError.cancel(standardOutput) // キャンセル途中までのの標準出力を返す
}
}
if let _data = try? pipe.fileHandleForReading.readToEnd(),
終了ステータスの確認と標準出力を返す
- タイミングによって
process.terminationStatus
の取得に失敗するため、0.5sのディレイをいれています(必要ないかもしれません) - 終了ステータスが0以外であればエラーを返すようにしており、問題なければ標準出力を呼び出し元へ返します。
try? await Task.sleep(nanoseconds: 0_500_000_000) // wait 0.5s
if process.terminationStatus != 0 {
throw CommandError.exitStatusIsInvalid(process.terminationStatus, standardOutput)
}
return standardOutput
サンドボックスの削除
-
xcodebuild
などコマンドによってはSandboxでうまく動作しないため、こちらの削除を行っておくと良いです。