Files
UTM/Services/UTMHelperProcess.swift
T
osy 3359543d2c ios: run qemu-img in a helper extension on iOS 26
QEMU tools cannot run in the app process on iOS: a process may load QEMU
only once and qemu-img calls exit(). So disk snapshots of a stopped VM,
reclaiming space, compression, resizing and conversion on import were
macOS-only.

iOS 26 brings ExtensionKit, so qemu-img now runs in an ExtensionKit
extension (iOSHelper, and iOSHelper-SE for UTM SE since an extension's
bundle identifier must be prefixed by its host's). The helper speaks the
same QEMUHelperProtocol as the macOS XPC helper but loads the tool into
its own process, one tool per process. UTMProcess accepts an injected
connection so the remote path is shared with macOS, including file
access through bookmarks, whose implicit security scope grants the
resolving process access. UTMHelperProcess launches the helper and,
because the system hands a process that is still exiting to a launch
that comes too soon, tears it down with a handshake on the interruption
callback and runs helpers one at a time.

UTMQemuImage now reads the tool's output until it ends instead of
racing the exit message, resumes its continuations off the XPC reply
queue (a call made from that queue blocks in NSXPC), and stops the
process explicitly since the connection's exported object kept it from
ever being deallocated on macOS. The last stderr line becomes the error
text when the tool gives none.

Assisted-by: Claude:claude-fable-5-1
2026-09-23 23:10:06 -05:00

198 lines
6.3 KiB
Swift

//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#if os(iOS) || os(visionOS)
import Foundation
import ExtensionFoundation
@available(iOS 26, visionOS 26, *)
extension AppExtensionPoint {
/// Hosts the helper that runs QEMU tools such as qemu-img out of process
@Definition
static var qemuHelper: AppExtensionPoint {
Name("qemu-helper")
UserInterface(false)
}
}
/// A helper extension process that runs a QEMU tool once for the app.
///
/// Helpers run one at a time: the system hands out a helper process that is still going away
/// to a launch that comes too soon after it, and each process can run QEMU only once.
final class UTMHelperProcess {
/// The helper extension exists in this build and can run on this system
static var isSupported: Bool {
if #available(iOS 26, visionOS 26, *) {
return true
} else {
return false
}
}
private static let gate = UTMHelperProcessGate()
private static let exitTimeout: TimeInterval = 2
/// Connection to the helper, to be configured and resumed by the owner
let connection: NSXPCConnection
/// The helper went away before it was asked to, such as when it failed to start
private(set) var isLost = false
/// Set when the system has seen the helper process go away
private let exit: UTMHelperProcessSignal
private let invalidation: () -> Void
private var isInvalidated = false
private init(connection: NSXPCConnection, exit: UTMHelperProcessSignal, invalidation: @escaping () -> Void) {
self.connection = connection
self.exit = exit
self.invalidation = invalidation
}
deinit {
invalidate()
}
/// Launch a helper and connect to it, waiting for any helper that is still running
static func launch() async throws -> UTMHelperProcess {
guard #available(iOS 26, visionOS 26, *) else {
throw UTMHelperProcessError.unsupported
}
await gate.acquire()
do {
let monitor = try await AppExtensionPoint.Monitor(appExtensionPoint: .qemuHelper)
guard let identity = monitor.identities.first else {
throw UTMHelperProcessError.notFound
}
let exit = UTMHelperProcessSignal()
let configuration = AppExtensionProcess.Configuration(appExtensionIdentity: identity) {
exit.signal()
}
let process = try await AppExtensionProcess(configuration: configuration)
let connection = try process.makeXPCConnection()
let helper = UTMHelperProcess(connection: connection, exit: exit) {
process.invalidate()
}
connection.interruptionHandler = { [weak helper] in
helper?.isLost = true
}
return helper
} catch {
await gate.release()
throw error
}
}
/// Ask the helper to exit and wait for the system to see it go, then let the next helper launch
func terminate() async {
guard !isInvalidated else {
return
}
isInvalidated = true
// this exit is the one asked for
connection.interruptionHandler = nil
(connection.remoteObjectProxy as? QEMUHelperProtocol)?.terminate()
await exit.wait(timeout: Self.exitTimeout)
connection.invalidate()
invalidation()
await Self.gate.release()
}
/// End the helper without waiting for it
func invalidate() {
guard !isInvalidated else {
return
}
isInvalidated = true
connection.invalidate()
invalidation()
Task {
await Self.gate.release()
}
}
}
/// Lets one helper run at a time
private actor UTMHelperProcessGate {
private var isHeld = false
private var waiters = [CheckedContinuation<Void, Never>]()
func acquire() async {
guard isHeld else {
isHeld = true
return
}
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
func release() {
if waiters.isEmpty {
isHeld = false
} else {
waiters.removeFirst().resume()
}
}
}
/// A one-time event that can be waited for with a timeout
private final class UTMHelperProcessSignal: @unchecked Sendable {
private let lock = NSLock()
private var isSignaled = false
private var continuation: CheckedContinuation<Void, Never>?
func signal() {
lock.lock()
isSignaled = true
let continuation = self.continuation
self.continuation = nil
lock.unlock()
continuation?.resume()
}
func wait(timeout: TimeInterval) async {
await withCheckedContinuation { continuation in
lock.lock()
guard !isSignaled else {
lock.unlock()
continuation.resume()
return
}
self.continuation = continuation
lock.unlock()
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
self.signal()
}
}
}
}
enum UTMHelperProcessError: Error {
case unsupported
case notFound
case lost
}
extension UTMHelperProcessError: LocalizedError {
var errorDescription: String? {
switch self {
case .unsupported: return NSLocalizedString("This operation requires a newer version of iOS.", comment: "UTMHelperProcess")
case .notFound: return NSLocalizedString("The helper for this operation is not installed.", comment: "UTMHelperProcess")
case .lost: return NSLocalizedString("The helper for this operation quit unexpectedly.", comment: "UTMHelperProcess")
}
}
}
#endif