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
?