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

## 概要
- Swift Concurrencyを使ってProcessでコマンドを実行し、キャンセルや並列に実行できるように実装してみました。
    
<img width="400" alt="image" src="https://i.imgur.com/IkP2fLT.jpg">

- 呼び出し例としてはコマンドをバックグラウンドで実行させ、キャンセルボタンから処理を中断できるようにしたり…

```swift
@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`を並列に実行することができます。

```swift
// 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
        }
    }
}
```

- 実行結果の例は以下の通りで、標準出力で結果を取得することができます。

```sh
# 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](https://speakerdeck.com/lovee/swift-as-a-scripting-language)
- [「偏見と妄想で語るスクリプト言語としての Swift」登壇補足](https://qiita.com/lovee/items/3c405e73992caef782dd)
- [Swift Concurrency チートシート](https://zenn.dev/koher/articles/swift-concurrency-cheatsheet)
- [AWS S3（2）S3Sync：S3バックアップツール](https://tech.blog.surbiton.jp/tag/nstask/)
- [SwiftからPOSIXコマンドを呼び出す](https://qiita.com/IKEH/items/6755f9738d733f6f7f48)

## GitHub
- https://github.com/pommdau/command-async-demo

## 実装
### コマンドを非同期に実行するメソッド(コード全体)
- 全体のコードとしては以下の通り。
- 詳細は以降に記載します。

```swift
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`で取得できるようにしています。
    - 参考: [Swiftでカスタムエラー実装するときはLocalizedErrorプロトコルに準拠させると良い](https://qiita.com/takehilo/items/cf384e5a3ab2c22163d7)

```swift
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](https://developer.apple.com/documentation/foundation/process)
>You can only run a Process object once. Subsequent attempts raise an error.

- 返り値としては`String`の標準出力を返すようにしており、また実行中にエラーが発生した際はエラーを投げるようにしています。

```swift
@discardableResult
static func execute(command: String, currentDirectoryURL: URL? = nil) async throws -> String {
...
}
```

- 引数の`currentDirectoryURL`に関しては、コマンドの実行時のパスを指定できます。
    - [currentDirectoryURL](https://developer.apple.com/documentation/foundation/process/2890107-currentdirectoryurl)
- 例えば以下のようにDesktop上の`ls`の実行結果を取得することができます。

```swift
try await Command.execute(command: "ls -l@", currentDirectoryURL: URL(fileURLWithPath: "/Users/ikeh/Desktop/"))
```

### Processの設定と実行
- Processの初期設定と実行部分の実装です。
    - `NSTask`の慣習から`let task = Process()`と変数名をつける例も多いのですが、Swift Concurrencyの`Task`とややこしいので`process`としています。
- [launch\(\)](https://developer.apple.com/documentation/foundation/process/1414189-launch)はDeprecatedなので[run\(\)](https://developer.apple.com/documentation/foundation/process/2890105-run)を使用します。

```swift
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 の実装側①）](https://zenn.dev/koher/articles/swift-concurrency-cheatsheet#%F0%9F%92%BC-case-12-(task.checkcancellation())%3A-%E9%9D%9E%E5%90%8C%E6%9C%9F%E5%87%A6%E7%90%86%E3%81%AE%E3%82%AD%E3%83%A3%E3%83%B3%E3%82%BB%E3%83%AB%EF%BC%88%E9%9D%9E%E5%90%8C%E6%9C%9F-api-%E3%81%AE%E5%AE%9F%E8%A3%85%E5%81%B4%E2%91%A0%EF%BC%89)
    - もしキャンセルが呼ばれている場合、プロセスを`terminate`で停止してからエラーを投げます。
- キャンセルのチェック後、毎回`pipe.fileHandleForReading.availableData`で標準出力を吸い出すようにしています。
    - このとき`readToEnd()`ではpingなどのキャンセル時に途中経過が取得できないので`availableData`を使用しています。
- また[readDataToEndOfFile\(\)](https://developer.apple.com/documentation/foundation/filehandle/1411490-readdatatoendoffile)はDeprecatedのため、[readToEnd\(\)](https://developer.apple.com/documentation/foundation/filehandle/3516318-readtoend)を使用します。

```swift
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での質問](https://discord.com/channels/291054398077927425/291211035438874625/1122894808369868871)

>[Objective-C, NSTask Buffer Limitation](https://stackoverflow.com/questions/55275078/objective-c-nstask-buffer-limitation)
>The NSPipe buffer limit seems to be 4096 bytes

```swift
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以外であればエラーを返すようにしており、問題なければ標準出力を呼び出し元へ返します。

```swift
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](https://i.imgur.com/3ScoyWw.png)