Swift 6 Concurrency + NSPipe Readability Handlers

Context

I'm using Process (formerly NSTask) to run a binary and retrieve its version string. This takes 2-3 seconds, so I want to do it on a background thread. The code, abbreviated, looks like this:

func version() async -> String 
{
    let process = Process()
    var errorData: Data = Data()
    var outputData: Data = Data()
        
    let errorPipe = Pipe()
    process.standardError = errorPipe
    errorPipe.fileHandleForReading.readabilityHandler = { handle in
               
        let newData: Data = handle.availableData

        if newData.count == 0 {
            handle.readabilityHandler = nil        // end of data signal is an empty data object.
        } else {
            errorData.append(newData)
        }
    }

    // (Repeat the above for the STDOUT pipe)

    process.executableURL = someURL
    process.arguments = ["--version"]
        
    try? process.run()
    process.waitUntilExit()
        
    if process.terminationStatus == 0,
       let fullString = String(data: outputData, encoding: .utf8)
    {
        return fullString
    }
        
    return "[Unknown]"
}

From a ViewController, I call this function like so:

Task.detached
{
    let version: String = await someController.version()
    DispatchQueue.main.async {
        // Update a textField in the UI to show the version
    }
}

The Problem

I get a new warning in Xcode 14 where I attempt to add new data to errorData in the errorPipe's readability handler:

Mutation of captured var 'errorData' in concurrently-executing code; this is an error in Swift 6

I understand the reasoning. So I rebuilt the function to use an Actor:

actor ToolVersion
{
    var process = Process()
    var errorData = Data()

    func version() -> String
    {
        let errorPipe = Pipe()
        process.standardError = outputPipe
        errorPipe.fileHandleForReading.readabilityHandler = { handle in

            let newData: Data = handle.availableData
                
            if newData.count == 0 {
                handle.readabilityHandler = nil       // end of data signal is an empty data object.
            } else {
                errorData.append(newData)               
            }
        }

        // Continue calling Process() as before
    }
}

This gave me a NEW arcane error:

Actor-isolated property 'errorData' can not be mutated from a Sendable closure

So as a last-ditch effort while simultaneously muttering, "Screw it, I'm going back to libdispatch; I don't have time for this nonsense," I tried this:

actor ToolVersion
{
    var process = Process()
    var errorData = Data()

    func version() -> String
    {
        let errorPipe = Pipe()
        process.standardError = outputPipe
        errorPipe.fileHandleForReading.readabilityHandler = { handle in

            let newData: Data = handle.availableData
            
            Task
            {
                if newData.count == 0 {
                    handle.readabilityHandler = nil       // end of data signal is an empty data object.
                } else {
                    errorData.append(newData)               
                }
            }    
        }

        // Continue calling Process() as before
    }
}

I recalled reading that Task inherits the isolation from whatever Actor it's dispatched from. The above now has no warnings but I have no idea if it's safe or good.

(Which is kinda the whole story with async/await in Swift. It demos well on trivial cases with boats and islands, but I've found it insanely difficult to adapt to "real world" APIs and use-cases.)

So... what's the right way to create my version() function so I can call it with async/await?

1 Like