Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,14 +91,15 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
name: imageName,
|
||||
ipswPath: ipswPath,
|
||||
config: frozenConfig,
|
||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||
)
|
||||
} catch {
|
||||
printer.finish()
|
||||
CLI.error("\(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)
|
||||
}
|
||||
printer.finish("done")
|
||||
|
||||
@@ -254,14 +255,15 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
name: imageName,
|
||||
config: frozenConfig,
|
||||
xcodeXIPPath: xipPath,
|
||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||
)
|
||||
} catch {
|
||||
printer.finish()
|
||||
CLI.error("\(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)
|
||||
}
|
||||
printer.finish("done")
|
||||
print("provisioned image '\(imageName)'")
|
||||
@@ -270,19 +272,36 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a stage to the progress printer.
|
||||
///
|
||||
/// Notes get a line of their own: they are the reason the operator is still
|
||||
/// watching, and a status line that is about to be overwritten is no place
|
||||
/// to put "this may be a truncated download".
|
||||
static func report(_ stage: ImageBuildStage, to printer: ProgressPrinter) {
|
||||
if case .note(let text) = stage {
|
||||
printer.line(text)
|
||||
} else {
|
||||
printer.update(describe(stage))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a build stage as one status line.
|
||||
static func describe(_ stage: ImageBuildStage) -> String {
|
||||
switch stage {
|
||||
case .downloadingIPSW(let fraction):
|
||||
return "downloading IPSW " + CLI.progressBar(fraction)
|
||||
case .preparing:
|
||||
return "preparing"
|
||||
case .creatingBundle:
|
||||
return "creating bundle"
|
||||
return "resolving restore image…"
|
||||
case .loadingRestoreImage:
|
||||
return "loading restore image metadata…"
|
||||
case .creatingBundle(let diskGB):
|
||||
return "creating VM bundle (disk \(diskGB) GB)…"
|
||||
case .note(let text):
|
||||
return text
|
||||
case .installing(let fraction):
|
||||
return "installing macOS " + CLI.progressBar(fraction)
|
||||
case .firstBoot:
|
||||
return "first boot (Setup Assistant)"
|
||||
return "first boot + guest provisioning…"
|
||||
case .provisioning(let step):
|
||||
return "provisioning: \(step)"
|
||||
case .finalizing:
|
||||
|
||||
@@ -85,9 +85,10 @@ struct VMCommand: AsyncParsableCommand {
|
||||
} catch {
|
||||
CLI.error("\(error)")
|
||||
await session.teardown()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -137,24 +137,50 @@ final class ProgressPrinter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lastLine = ""
|
||||
|
||||
/// Whether carriage-return rewriting means anything here.
|
||||
///
|
||||
/// Piped to a file or captured by `launchd`, `\r` produces one unreadable
|
||||
/// mega-line, so each update becomes its own line instead. `FileHandle`
|
||||
/// writes go straight to the descriptor either way — there is no buffer to
|
||||
/// flush, which is what makes a stall attributable to the stage last
|
||||
/// printed rather than to output sitting unwritten.
|
||||
private let isInteractive = isatty(fileno(stderr)) == 1
|
||||
|
||||
/// Rewrites the current line.
|
||||
func update(_ line: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard line != lastLine else { return }
|
||||
lastLine = line
|
||||
guard isInteractive else {
|
||||
FileHandle.standardError.write(Data((line + "\n").utf8))
|
||||
return
|
||||
}
|
||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
||||
}
|
||||
|
||||
/// Emits a standalone line without losing the status line under it.
|
||||
func line(_ text: String) {
|
||||
lock.lock()
|
||||
let carried = lastLine
|
||||
lock.unlock()
|
||||
|
||||
finish(text)
|
||||
if !carried.isEmpty {
|
||||
update(carried)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the line so subsequent output starts cleanly.
|
||||
func finish(_ line: String? = nil) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let line {
|
||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
|
||||
} else if !lastLine.isEmpty {
|
||||
let padding = isInteractive ? String(repeating: " ", count: max(0, 78 - line.count)) : ""
|
||||
let prefix = isInteractive ? "\r" : ""
|
||||
FileHandle.standardError.write(Data((prefix + line + padding + "\n").utf8))
|
||||
} else if !lastLine.isEmpty, isInteractive {
|
||||
FileHandle.standardError.write(Data("\n".utf8))
|
||||
}
|
||||
lastLine = ""
|
||||
|
||||
Reference in New Issue
Block a user