Search code examples
swiftmultithreadingcocoaxcode9freeze

Mac Cocoa app GUI freezing while Process running


I've made a basic Mac OS X Cocoa application with Swift in Xcode 9. The app gathers a source and a destination from the user, and then transfers the data from source to destination. The data transfer is done by launching an rsync script as a Process:

let path = "/bin/bash"
let arguments = ["/path/to/backup.sh", sourcePath, destinationPath]
task = Process.launchedProcess(launchPath: path, arguments: arguments as! [String])

The problem is that, while the transfer is running, the app gets the spinning rainbow wheel and the GUI can't be used. This makes a 'Cancel' button or a functional progress bar impossible.

Once the transfer finishes, the app becomes functional again, and any test output (e.g., print statements, progress bar) goes through (instead of going through while the backup was running like it was supposed to).

I thought threads might help resolve this issue, so I looked into this post about threads from Apple: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/CreatingThreads/CreatingThreads.html

However, the code in the article doesn't seem to have correct syntax in Xcode, so I'm not sure how to continue with threads.

I'm at a dead end, any help would be appreciated!

After running the app, pausing in the debugger, and typing 'bt' in the debugger console, this is the output:

* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
frame #0: 0x00007fff65fc120a libsystem_kernel.dylib`mach_msg_trap + 10
frame #1: 0x00007fff65fc0724 libsystem_kernel.dylib`mach_msg + 60
frame #2: 0x00007fff3dac4045 CoreFoundation`__CFRunLoopServiceMachPort + 341
frame #3: 0x00007fff3dac3397 CoreFoundation`__CFRunLoopRun + 1783
frame #4: 0x00007fff3dac2a07 CoreFoundation`CFRunLoopRunSpecific + 487
frame #5: 0x00007fff3cda0d96 HIToolbox`RunCurrentEventLoopInMode + 286
frame #6: 0x00007fff3cda0b06 HIToolbox`ReceiveNextEventCommon + 613
frame #7: 0x00007fff3cda0884 HIToolbox`_BlockUntilNextEventMatchingListInModeWithFilter + 64
frame #8: 0x00007fff3b053a73 AppKit`_DPSNextEvent + 2085
frame #9: 0x00007fff3b7e9e34 AppKit`-[NSApplication(NSEvent) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] + 3044
frame #10: 0x00007fff3b048885 AppKit`-[NSApplication run] + 764
frame #11: 0x00007fff3b017a72 AppKit`NSApplicationMain + 804
* frame #12: 0x000000010000a88d Mac Syncy`main at AppDelegate.swift:12
frame #13: 0x00007fff65e7a015 libdyld.dylib`start + 1
frame #14: 0x00007fff65e7a015 libdyld.dylib`start + 1

Solution

  • So, I found out several things:

    1. In fact, launchedProcess causes the process to be launched immediately. Instead, use init to gain more control
    2. If you call waitForExit, then the current thread will wait until the end of the process.
    3. When you launch an process, this will run independently from your App. So if you quit your app, the launched process still continues running.

    So let' start (completely working view controller at the very end):

    1. Creating the Process

    task = Process.init()
    task.launchPath = path
    task.arguments = arguments
    task.currentDirectoryPath = workDir
    // not running yet, so let's start it:
    task.lauch()
    

    2. Wait for the end of child process asynchronously

    DispatchQueue.global().async {
        print ("wating for exit")
        self.task.waitUntilExit()
    }
    

    3. Kill child process

    You'll have to terminate the task e.g. in applicationWillTerminate in the application delegate.

    Nevertheless, you should be aware that this could lead your (rsync) operation to stay in an undeterminated state - file/directorys only being half-copied etc.

    4. Bonus: Progress indicator

    I think the only way to provide a progress indicator is to parse the output of the Process (task.standardOutput) and check wheather rsync provides useful information here. But this is a completly new story, so no code here, sorry.

    The code

    This is a view controller with a start and a cancel button. Keep in mind, that for a shipped application, you'll have to provide more error checking.

    class ViewController: NSViewController {
    
        var task:Process!
        var out:FileHandle?
        var outputTimer: Timer?
    
    
        @IBAction func startPressed(_ sender: NSButton) {
            print("** starting **")
    
            let path = "/bin/bash"
            let workDir = "/path/to/working/folder"
            let sourcePath = "source"
            let destinationPath = "destination"
    
            let arguments = ["backup.sh", sourcePath, destinationPath]
            task = Process.init()
            task.launchPath = path
            task.arguments = arguments
            task.currentDirectoryPath = workDir
    
            self.task.launch()
            DispatchQueue.global().async {
                // this runs in a worker thread, so the UI remains responsive
                print ("wating for exit")
                self.task.waitUntilExit()
                DispatchQueue.main.async {
                    // do so in the main thread
                    if let timer = self.outputTimer {
                        timer.invalidate()
                        self.outputTimer = nil
                    }
                }
            }
    
        }
    
    
        @IBAction func cancelPressed(_ sender: NSButton) {
            print("** cancelling **")
            if let timer = self.outputTimer {
                timer.invalidate()
                self.outputTimer = nil
            }
            task.interrupt()
        }
    }