Skip to content

Instantly share code, notes, and snippets.

@pommdau
Last active July 3, 2023 01:41
Show Gist options
  • Save pommdau/6d0e1089f52ec50748b6732413303194 to your computer and use it in GitHub Desktop.
Save pommdau/6d0e1089f52ec50748b6732413303194 to your computer and use it in GitHub Desktop.

概要

  • やりたいことは、コマンドをasyncで非同期に呼び出せるようすること。
    • バックグラウンドで処理を実行させ、キャンセルボタンで処理を中断できるようにする

image

問題点

  • 問題として、xcodebuildを行ったときに処理が完了しないが
    • 具体的にはprocess.isRunningfalseにならない
    • echoなどを呼び出した際は問題なく処理できる
  • また全く同じコマンドをterminal上で行った場合は処理が完了している

image

参考

コード

  • 事前にSandboxの削除を行う

image

import Foundation

struct Command {
    /// completion版
    static func execute(command: String, currentDirectoryURL: URL? = nil, completion: @escaping (Result<String, CommandError>) -> ()) {
        let process = Process()
        process.launchPath = "/bin/zsh"
        process.arguments = ["-cl", command]
        process.launchPath = "/bin/zsh"
        process.currentDirectoryURL = currentDirectoryURL
        
        let pipe = Pipe()
        process.standardOutput = pipe
        process.standardError = pipe
        process.standardOutput = pipe
        process.standardError = pipe
        
        do {
            try process.run()
        } catch {
            completion(.failure(.failedInRunning))
            return
        }
        
        // Processが完了するまで、Taskがキャンセルされていないかを監視
        while process.isRunning {  // xcodebuildを呼び出した際にここがfalseにならない
            do {
                try Task.checkCancellation()
            } catch {
                process.terminate()
                completion(.failure(.cancel))
                return
            }
        }
                                
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        Thread.sleep(forTimeInterval: 0.5) // Taskの終了を待つためのDelay(必要?)
        
        let output = String(data: data, encoding: .utf8) ?? ""
        if process.terminationStatus != 0 {
            completion(.failure(.exitStatusIsInvalid(process.terminationStatus, output)))
            return
        }
        print(output)
        completion(.success(output))
    }
    
    /// async版
    @discardableResult
    static func execute(command: String, currentDirectoryURL: URL? = nil) async throws -> String {
        try await withCheckedThrowingContinuation { continuation in
            execute(command: command, currentDirectoryURL: currentDirectoryURL) { result in
                do {
                    let output = try result.get()
                    continuation.resume(returning: output)
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }    
}

// MARK: - Command Error

enum CommandError: Error {
    case cancel  // Taskがキャンセルされた
    case failedInRunning  // process.run()でエラーが発生
    case exitStatusIsInvalid(Int32, String) // 終了ステータスが0以外
}

extension CommandError: LocalizedError {
    
    var title: String {
        switch self {
        case .cancel:
            return "処理をキャンセルしました。"
        case .failedInRunning:
            return "コマンドの実行中にエラーが発生しました"
        case .exitStatusIsInvalid(let status, _):
            return "コマンドの実行に失敗しました。終了コード: \(status)"
        }
    }
        
    var errorDescription: String? {
        switch self {
        case .cancel, .failedInRunning:
            return nil
        case .exitStatusIsInvalid(_, let output):
            return output
        }
    }
}

// MARK: - Command + sample

extension Command {
    struct sample {
        static var echo: String {
            "echo ~'/Desktop'"
        }
        
        static var xcodebuild: String {
            """
            xcodebuild \
            -scheme "BuildSampleProject" \
            -project ~/"Downloads/tmp/BuildSampleProject/BuildSampleProject.xcodeproj" \
            -configuration "Release" \
            -archivePath ~"/Downloads/tmp/BuildSampleProject/build/BuildSampleProject.xcarchive" \
            archive
            """
        }
        
        static var ping: String {
            "ping google.co.jp"
        }
        
        static var ls: String {
            "ls -l@ ~/Desktop"
        }
    }                
}

// MARK: - View

import SwiftUI

struct ContentView: View {
    
    @State var task: Task<(), Never>?
    @State var isProcessing = false
    
    var body: some View {
        VStack {
            HStack {
                Button("echo") {
                    handleButtonClicked(command: Command.sample.echo)
                }
                Button("ping") {
                    handleButtonClicked(command: Command.sample.ping)
                }
                Button("xcodebuild") {
                    handleButtonClicked(command: Command.sample.xcodebuild)
                }
                if isProcessing {
                    ProgressView()
                        .scaleEffect(0.5)
                }
            }
            .frame(width: 300, height: 60)
            .disabled(isProcessing)
                                                                                                
            Button("Cancel") {
                isProcessing = false
                task?.cancel()
                task = nil
            }
            .disabled(!isProcessing)
        }
    }
    
    private func handleButtonClicked(command: String) {
        isProcessing = true
        task = Task {
            defer {
                isProcessing = false
            }
            do {
                try await Command.execute(command: command)
            } catch {
                print(error)
                return
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
@pommdau
Copy link
Author

pommdau commented Jun 23, 2023

process.isRunningの部分をコメントアウトしてxcodebuildを呼び出した場合は正常に終了している

        // Processが完了するまで、Taskがキャンセルされていないかを監視
//        while process.isRunning {
//            print(process.isRunning)
//            do {
//                try Task.checkCancellation()
//            } catch {
//                process.terminate()
//                completion(.failure(.cancel))
//                return
//            }
//        }
        
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        Thread.sleep(forTimeInterval: 0.5) // Taskの終了を待つためのDelay(必要?)
        print(process.isRunning)
        print(process.terminationStatus)
false
0

@pommdau
Copy link
Author

pommdau commented Jun 30, 2023

Discordで問題点やアドバイスをいただいたので修正
https://discord.com/channels/291054398077927425/291211035438874625/1122894808369868871

/// https://tech.blog.surbiton.jp/tag/nstask/

import Foundation

struct Command {
    
    /// completion版
    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)
            }
            // readDataToEndOfFile()では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)
        }
        
        // 残りの標準出力の取得
        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)
        if process.terminationStatus != 0 {
            throw CommandError.exitStatusIsInvalid(process.terminationStatus, standardOutput)
        }
                        
        return standardOutput
    }
}

// MARK: - Command Error

enum CommandError: Error {
    case cancel(String)  // Taskがキャンセルされた
    case failedInRunning  // process.run()でエラーが発生
    case exitStatusIsInvalid(Int32, String) // 終了ステータスが0以外
}

extension CommandError: LocalizedError {
    
    var title: String {
        switch self {
        case .cancel:
            return "処理をキャンセルしました。"
        case .failedInRunning:
            return "コマンドの実行中にエラーが発生しました"
        case .exitStatusIsInvalid(let status, _):
            return "コマンドの実行に失敗しました。終了コード: \(status)"
        }
    }
        
    var errorDescription: String? {
        switch self {
        case .cancel, .failedInRunning:
            return nil
        case .exitStatusIsInvalid(_, let output):
            return output
        }
    }
}

// MARK: - Command + sample

extension Command {
    struct sample {
        static var echo: String {
            "echo ~'/Desktop'"
        }
        
        static var xcodebuild: String {
            """
            xcodebuild \
            -scheme "BuildSampleProject" \
            -project ~/"Downloads/tmp/BuildSampleProject/BuildSampleProject.xcodeproj" \
            -configuration "Release" \
            -archivePath ~"/Downloads/tmp/BuildSampleProject/build/BuildSampleProject.xcarchive" \
            archive
            """
        }
        
        static var ping: String {
            "ping google.co.jp"
        }
        
        static var ls: String {
            "ls -l@ ~/Desktop"
        }
    }
}

// MARK: - View

import SwiftUI

struct ContentView: View {
    
    @State var task: Task<(), Never>?
    @State var isProcessing = false
    
    var body: some View {
        VStack {
            HStack {
                Button("echo") {
                    handleButtonClicked(command: Command.sample.echo)
                }
                Button("ping") {
                    handleButtonClicked(command: Command.sample.ping)
                }
                Button("xcodebuild") {
                    handleButtonClicked(command: Command.sample.xcodebuild)
                }
                if isProcessing {
                    ProgressView()
                        .scaleEffect(0.5)
                }
            }
            .frame(width: 300, height: 60)
            .disabled(isProcessing)
                                                                                                
            Button("Cancel") {
                isProcessing = false
                task?.cancel()
                task = nil
            }
            .disabled(!isProcessing)
        }
    }
    
    private func handleButtonClicked(command: String) {
        isProcessing = true
        task = Task {
            defer {
                isProcessing = false
            }
            do {
//                try await Command.execute(command: command)
                let output = try await Command.execute(command: command)
                print(output)
            } catch {
                print(error)
                return
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

@pommdau
Copy link
Author

pommdau commented Jul 3, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment