Don't run the AppKit run loop nested in Swift's async main (#1260)

When built against the macOS 27 (Xcode 27, Swift 6.4) SDK, "tart run"
brings up the VM window but the guest never boots.

Swift's asynchronous main() entry point implicitly starts an executor
that owns the main thread, and as of Swift 6.4 that executor is no
longer backed by the Dispatch main queue. Running an AppKit/SwiftUI
run loop nested inside it via MainApp.main() leaves the main run loop
unable to drain Swift tasks or DispatchQueue.main, so the task that
starts the VM is never scheduled, even though the window itself
(driven directly by AppKit during launch) still appears.

We now keep Root.main() synchronous, so that a command driving a run
loop can own the main thread at the top level, exactly like a plain
SwiftUI app. With AppKit owning the loop again, MainActor tasks and
the Dispatch main queue drain as before. Such commands opt in through
a new MainThreadCommand protocol; everything else keeps running
asynchronously via a detached task and dispatchMain().

Verified that the guest boots again, and that Ctrl+C still stops the
VM gracefully.
This commit is contained in:
Tor Arne Vestbø 2026-06-09 18:53:06 +02:00 committed by GitHub
parent 6ada2b955d
commit 2e63759c1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 143 additions and 60 deletions

View File

@ -360,7 +360,7 @@ struct Run: AsyncParsableCommand {
}
@MainActor
func run() async throws {
func runOnMainThread() throws {
let localStorage = try VMStorageLocal()
let vmDir = try localStorage.open(name)
@ -758,6 +758,11 @@ struct Run: AsyncParsableCommand {
}
}
// "tart run" drives an AppKit/SwiftUI run loop and therefore must own the main
// thread at the top level, so it opts out of Root's asynchronous command path.
// See Root.main() for the rationale.
extension Run: MainThreadCommand {}
struct MainApp: App {
static var suspendable: Bool = false
static var capturesSystemKeys: Bool = false

View File

@ -32,86 +32,157 @@ struct Root: AsyncParsableCommand {
FQN.self,
])
public static func main() async throws {
// Note: main() is intentionally synchronous. Swift's asynchronous main() entry
// point implicitly starts an executor that owns the main thread and since
// Swift 6.4 that executor is no longer backed by the Dispatch main queue so
// running an AppKit/SwiftUI run loop nested inside it leaves the main run loop
// unable to drain Tasks or DispatchQueue.main, and a VM started via "tart run"
// never boots. Keeping main() synchronous lets a command that needs the main
// run loop own it at the top level, exactly like a plain SwiftUI app.
public static func main() {
// Add commands that are only available on specific macOS versions
if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self)
}
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
// Handle cancellation by Ctrl+C ourselves
let task = withUnsafeCurrentTask { $0 }!
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
// Ensure the default SIGINT handler is disabled, otherwise there's a race
// between two handlers. We handle cancellation by Ctrl+C ourselves below.
signal(SIGINT, SIG_IGN)
// Set line-buffered output for stdout
setlinebuf(stdout)
defer { OTel.shared.flush() }
// Parse the command up-front, synchronously, so we can decide who gets to own
// the main thread before any concurrency is involved.
//
// ParsableCommand isn't Sendable, but we only ever hand it to the single task
// spawned below and never touch it again afterwards, so transferring it into
// that task is safe.
nonisolated(unsafe) let command: ParsableCommand
do {
command = try parseAsRoot()
} catch {
exit(withError: error)
}
if let mainThreadCommand = command as? MainThreadCommand {
// This command drives a run loop on the main thread, so run it right here,
// letting it own the main thread at the top level.
MainActor.assumeIsolated {
runOnMainThread(mainThreadCommand)
}
} else {
// Every other command is asynchronous and doesn't touch the main thread, so
// drive it from a detached task and let the Dispatch main queue keep the
// process alive until the command exits.
let task = Task.detached {
await runInBackground(command)
}
// Handle cancellation by Ctrl+C ourselves
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
dispatchMain()
}
}
@MainActor
private static func runOnMainThread(_ command: MainThreadCommand) {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
do {
// Parse command
var command = try parseAsRoot()
// Enters the run loop and only returns once the command exits via
// Foundation.exit(), so the lines below are a best-effort fallback.
try command.runOnMainThread()
} catch {
handleError(error, span: span)
}
// Create a root span for the command we're about to run
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
defer { span.end() }
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
// Enrich root command span with command's arguments
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
AttributeValue.string(argument)
}
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
private static func runInBackground(_ command: ParsableCommand) async {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
// Enrich root command span with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
span.setAttribute(key: key, value: .string(value))
}
}
// Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection: \(error)\n", stderr)
}
}
// Run command
do {
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
var command = command
try command.run()
}
} catch {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
exit(withError: error)
handleError(error, span: span)
}
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
// Create a root span for the command we're about to run.
private static func startCommandSpan(for command: ParsableCommand) -> Span {
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
// Enrich root command span with command's arguments
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
AttributeValue.string(argument)
}
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
// Enrich root command span with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
span.setAttribute(key: key, value: .string(value))
}
}
return span
}
// Run garbage-collection before each command (shouldn't take too long).
private static func runGarbageCollection(for command: ParsableCommand) {
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()) {
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection: \(error)\n", stderr)
}
}
}
private static func handleError(_ error: Error, span: Span) -> Never {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
span.end()
OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
span.end()
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
OTel.shared.flush()
exit(withError: error)
}
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
@ -123,3 +194,10 @@ struct Root: AsyncParsableCommand {
return (String(splits[0]), String(splits[1]))
}
}
// A command that drives an AppKit/SwiftUI run loop and therefore has to own the
// main thread at the top level, rather than running inside Swift's asynchronous
// main() executor. See Root.main() for the rationale.
protocol MainThreadCommand: ParsableCommand {
@MainActor func runOnMainThread() throws
}