2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Swift Concurrencyを使ってProcessのキャンセルや並列に実行できるようにする

Last updated at Posted at 2023-06-30

概要

  • Swift Concurrencyを使ってProcessでコマンドを実行し、キャンセルや並列に実行できるように実装してみました。
image
  • 呼び出し例としてはコマンドをバックグラウンドで実行させ、キャンセルボタンから処理を中断できるようにしたり…
@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 
()

参考

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
    }
}

コマンド実行用のエラー定義

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()を定期的に呼び出しています。
  • キャンセルのチェック後、毎回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.isRunningfalseにならず処理が止まってしまう問題がありました。
  • これはPipeのバッファの制限があり、標準出力に書き込もうとしているができずに処理が止まっているようでした。

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でうまく動作しないため、こちらの削除を行っておくと良いです。

image

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?