import AppKit import ArgumentParser import Foundation import Logging import RunnerCore import RunnerHost /// `gitea-macos-runner daemon` — the long-running service. /// /// ## Why there is an `NSApplication` here /// /// Virtualization.framework requires a running main run loop in an application /// context; a plain command-line process that blocks in `await` never services /// it, and VM startup either hangs or fails. The fix is to start a real /// `NSApplication` but suppress every trace of a GUI: /// /// ```swift /// NSApplication.shared.setActivationPolicy(.prohibited) // no Dock icon, no menu bar /// // spawn the orchestrator Task /// NSApplication.shared.run() // never returns /// ``` /// /// `.prohibited` (mirrored by `LSUIElement` in `Info.plist`) is what makes this /// invisible. The orchestrator runs in a detached `Task`; `run()` owns the main /// thread from then on. /// /// `SIGTERM` and `SIGINT` are trapped with `DispatchSourceSignal` — not /// `signal(2)` handlers, which cannot safely touch Swift concurrency — and /// trigger ``Orchestrator/shutdown()`` before the process leaves, so guests get /// a chance to stop cleanly instead of having their disks yanked. struct DaemonCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "daemon", abstract: "Watch Gitea for queued macOS jobs and run each in a fresh VM." ) @OptionGroup var options: GlobalOptions /// Base image to clone for each job. @Option(name: .long, help: "Base image to clone for each job.") var image: String = "default" /// Run one poll/reconcile tick and exit. Useful for debugging without /// installing the service. @Flag(name: .long, help: "Run a single scheduling tick, then exit.") var once: Bool = false func run() async throws { CLI.bootstrapLogging(verbose: options.verbose) let logger = Logger(label: "daemon") let config = try options.loadConfig() guard let adminToken = try config.resolveAdminToken(), !adminToken.isEmpty else { throw ValidationError( """ no Gitea admin token: set gitea.adminTokenFile (preferred) or gitea.adminToken \ in \(RunnerConfig.expandTilde(options.configPath)) """ ) } for path in config.insecureTokenFilePaths { logger.warning("token file is group/world readable", metadata: ["path": .string(path)]) } let store = VMStore(config: config) try store.ensureLayout() guard try store.image(named: image) != nil else { throw ValidationError( "no base image named '\(image)' — build one with `gitea-macos-runner image build --name \(image)`" ) } let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken) let orchestrator = Orchestrator( config: config, client: client, store: store, imageName: image, logger: Logger(label: "orchestrator") ) let singleTick = once let jobTimeout = TimeInterval(config.scheduler.jobTimeoutMinutes * 60) // Even a single tick can start a VM, and a VM needs the run loop — so // both modes go through NSApplication. await VZAppRuntime.run( onSignal: { await orchestrator.shutdown() }, body: { do { if singleTick { await orchestrator.reconcileOnce() await orchestrator.tick() // Let whatever the tick started run to completion rather // than tearing a just-booted guest down mid-boot. let deadline = Date().addingTimeInterval(jobTimeout) var pending = await orchestrator.liveVMs().count while pending > 0, Date() < deadline { try? await Task.sleep(for: .seconds(5)) pending = await orchestrator.liveVMs().count } await orchestrator.shutdown() } else { try await orchestrator.runForever() } } catch is CancellationError { // Expected on shutdown. } catch { logger.critical("daemon stopped", metadata: ["error": .string("\(error)")]) // 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) } } ) } } /// Hosts an `NSApplication` run loop so Virtualization.framework has the main /// 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. enum VZAppRuntime { /// Signal sources have to outlive the call that creates them or they are /// cancelled on deinit and the signals go nowhere. 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 ) -> Never { let app = NSApplication.shared // No Dock icon, no menu bar, no activation: this is a background agent // that merely needs to be an application as far as the kernel is // concerned. app.setActivationPolicy(.prohibited) for signalNumber in [SIGINT, SIGTERM] { // 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: signalQueue) source.setEventHandler { guard beginTerminating() else { return } Task.detached { CLI.note("received signal; shutting down…") await onSignal() flushAndExit(0) } } source.resume() stateLock.lock() signalSources.append(source) stateLock.unlock() } // 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() flushAndExit(0) } app.run() 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) } }