Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 02:30:26 -07:00
parent d64b3f3e9b
commit b2f15883d8
9 changed files with 403 additions and 47 deletions
+59 -18
View File
@@ -110,9 +110,10 @@ struct DaemonCommand: AsyncParsableCommand {
// Expected on shutdown.
} catch {
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
}
)
@@ -123,18 +124,40 @@ struct DaemonCommand: AsyncParsableCommand {
/// run loop it requires, while the real work runs in a `Task`.
///
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
@MainActor
enum VZAppRuntime {
/// Signal sources have to outlive the call that creates them or they are
/// cancelled on deinit and the signals go nowhere.
private static var signalSources: [DispatchSourceSignal] = []
private static var isTerminating = false
private nonisolated(unsafe) static var signalSources: [DispatchSourceSignal] = []
private nonisolated(unsafe) static var isTerminating = false
private static let stateLock = NSLock()
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
private static let signalQueue = DispatchQueue(
label: "xyz.blakeslee.gitea-macos-runner.signals")
/// Starts the run loop and runs `body` alongside it. Never returns.
///
/// ## Nothing here may touch the main queue
///
/// This is reached from Swift's async `main`, so the frame that calls
/// `app.run()` is *itself* a block executing on the main dispatch queue —
/// and it never returns. libdispatch will not re-enter a serial queue that
/// already has a block in flight, so from this moment the main queue is
/// closed for business: a plain `Task { }` inheriting a `@MainActor`
/// context, a `DispatchSource` handler on `.main`, or an
/// `await MainActor.run { … }` all enqueue work that can never be drained.
///
/// The symptom is exact and was reported as a hang in `image build`: a live
/// run loop, zero CPU, and no output past the last line printed before this
/// call — `body` had been enqueued behind `app.run()` and never got a first
/// tick. Hence `Task.detached`, a private signal queue, and ``exit(_:)``
/// called straight from whichever thread reaches it. A normal AppKit app
/// does not hit this because its `main()` is not a main-queue block.
///
/// - Parameters:
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
/// - body: The work to run. When it returns, the process exits zero.
@MainActor
static func run(
onSignal: @escaping @Sendable () async -> Void,
body: @escaping @Sendable () async -> Void
@@ -149,30 +172,48 @@ enum VZAppRuntime {
// DispatchSourceSignal only observes; the default disposition still
// kills the process unless it is ignored first.
signal(signalNumber, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: signalQueue)
source.setEventHandler {
Task { @MainActor in
guard !isTerminating else { return }
isTerminating = true
guard beginTerminating() else { return }
Task.detached {
CLI.note("received signal; shutting down…")
await onSignal()
NSApp.terminate(nil)
exit(0)
flushAndExit(0)
}
}
source.resume()
stateLock.lock()
signalSources.append(source)
stateLock.unlock()
}
Task {
// Detached on purpose: an inheriting `Task { }` would be queued behind
// the `app.run()` below and never start. See the note above.
Task.detached {
await body()
await MainActor.run {
NSApp.terminate(nil)
exit(0)
}
flushAndExit(0)
}
app.run()
exit(0)
flushAndExit(0)
}
/// Wins the race to shut down, exactly once.
private static func beginTerminating() -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
guard !isTerminating else { return false }
isTerminating = true
return true
}
/// Exits from any thread, without hopping to the unusable main actor.
///
/// `NSApp.terminate(nil)` is deliberately not called: it requires the main
/// actor, which is exactly what is not available here.
nonisolated static func flushAndExit(_ code: Int32) -> Never {
fflush(stdout)
fflush(stderr)
exit(code)
}
}