Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -108,3 +108,80 @@ public enum PathResolution {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cheap sanity checks on a `.ipsw` before Virtualization.framework sees it.
|
||||||
|
///
|
||||||
|
/// Lives beside ``PathResolution`` because it is the other half of the same
|
||||||
|
/// job — what the CLI does with a path an operator typed — and because
|
||||||
|
/// `RunnerCore` is the only target that unit-tests on both platforms.
|
||||||
|
///
|
||||||
|
/// The checks earn their place: `VZMacOSRestoreImage.image(from:)` reports no
|
||||||
|
/// progress at all while it works, and on a partial download it can sit for a
|
||||||
|
/// very long time rather than failing. Without these, "I pointed it at the
|
||||||
|
/// wrong file" and "my 21 GB download stopped at 4 GB" both present to the
|
||||||
|
/// operator as an unexplained hang.
|
||||||
|
public enum IPSWFile {
|
||||||
|
/// The floor a real macOS restore image clears by an order of magnitude —
|
||||||
|
/// they run ~15-22 GB. Anything under this is a truncated download, a
|
||||||
|
/// placeholder, or the wrong file entirely.
|
||||||
|
public static let minimumBytes: Int64 = 1_000_000_000
|
||||||
|
|
||||||
|
/// The first two bytes of every `.ipsw`: an IPSW is a zip archive.
|
||||||
|
static let zipMagic = Data([0x50, 0x4B]) // "PK"
|
||||||
|
|
||||||
|
/// Fails fast if `path` cannot be a usable restore image.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - path: An already-resolved absolute path (see ``PathResolution``).
|
||||||
|
/// - label: What the file is, for error messages.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` if it is not there or unreadable,
|
||||||
|
/// ``CoreError/configInvalid(_:)`` if it is there but cannot be an IPSW.
|
||||||
|
public static func validate(path: String, label: String = "restore image") throws {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
|
||||||
|
throw CoreError.notFound("\(label) not found at \(path)")
|
||||||
|
}
|
||||||
|
guard !isDirectory.boolValue else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) is a directory, not an .ipsw file"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
guard size >= minimumBytes else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) is only \(describeSize(size)) — a macOS restore image is "
|
||||||
|
+ "~15-22 GB. The download is most likely incomplete; delete the file and "
|
||||||
|
+ "fetch it again."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let handle = FileHandle(forReadingAtPath: path) else {
|
||||||
|
throw CoreError.notFound("\(label) at \(path) could not be opened for reading")
|
||||||
|
}
|
||||||
|
defer { try? handle.close() }
|
||||||
|
|
||||||
|
let magic = (try? handle.read(upToCount: zipMagic.count)) ?? Data()
|
||||||
|
guard magic == zipMagic else {
|
||||||
|
let found = magic.map { String(format: "%02x", $0) }.joined()
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) does not look like an .ipsw: expected a zip archive "
|
||||||
|
+ "(magic \"PK\", 504b) but the file starts with \(found.isEmpty ? "nothing" : found). "
|
||||||
|
+ "Check the path, or re-download the file."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A byte count an operator can compare against "~15 GB" at a glance.
|
||||||
|
static func describeSize(_ bytes: Int64) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var value = Double(bytes)
|
||||||
|
var unit = 0
|
||||||
|
while value >= 1024, unit < units.count - 1 {
|
||||||
|
value /= 1024
|
||||||
|
unit += 1
|
||||||
|
}
|
||||||
|
return unit == 0 ? "\(Int(value)) B" : String(format: "%.1f %@", value, units[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,12 +155,10 @@ public struct IPSWProvider: Sendable {
|
|||||||
// not a Swift error — when handed a non-file or missing path, and an
|
// not a Swift error — when handed a non-file or missing path, and an
|
||||||
// ObjC exception cannot be caught here. So the existence check is not
|
// ObjC exception cannot be caught here. So the existence check is not
|
||||||
// politeness; it is the only thing standing between a typo and a crash.
|
// politeness; it is the only thing standing between a typo and a crash.
|
||||||
var isDirectory: ObjCBool = false
|
// The size and magic-byte checks alongside it cover the other way this
|
||||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
// call goes wrong: handed a partial download it neither fails nor
|
||||||
!isDirectory.boolValue
|
// reports progress, it simply stops responding.
|
||||||
else {
|
try IPSWFile.validate(path: url.path)
|
||||||
throw CoreError.notFound("restore image not found at \(url.path)")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try await VZMacOSRestoreImage.image(from: url)
|
return try await VZMacOSRestoreImage.image(from: url)
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import Virtualization
|
|||||||
public enum ImageBuildStage: Sendable, Equatable {
|
public enum ImageBuildStage: Sendable, Equatable {
|
||||||
/// Downloading the IPSW.
|
/// Downloading the IPSW.
|
||||||
case downloadingIPSW(fraction: Double)
|
case downloadingIPSW(fraction: Double)
|
||||||
/// Reading the restore image and deriving a hardware configuration.
|
/// Working out which file the operator meant and whether it is usable.
|
||||||
case preparing
|
case preparing
|
||||||
|
/// Inside `VZMacOSRestoreImage.image(from:)`, which reports no progress of
|
||||||
|
/// its own and is the longest silent stretch of a local-IPSW build.
|
||||||
|
case loadingRestoreImage
|
||||||
/// Creating the disk, NVRAM, and bundle metadata.
|
/// Creating the disk, NVRAM, and bundle metadata.
|
||||||
case creatingBundle
|
case creatingBundle(diskGB: Int)
|
||||||
|
/// An out-of-band remark about the stage in flight — printed on its own
|
||||||
|
/// line rather than replacing the status line.
|
||||||
|
case note(String)
|
||||||
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
||||||
case installing(fraction: Double)
|
case installing(fraction: Double)
|
||||||
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
||||||
@@ -89,7 +95,28 @@ public struct ImageBuilder: Sendable {
|
|||||||
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
||||||
let restoreImage: VZMacOSRestoreImage
|
let restoreImage: VZMacOSRestoreImage
|
||||||
if let ipswPath {
|
if let ipswPath {
|
||||||
progress?(.downloadingIPSW(fraction: 1.0))
|
progress?(.preparing)
|
||||||
|
progress?(.loadingRestoreImage)
|
||||||
|
|
||||||
|
// Reading a 21 GB archive's metadata takes a while and the
|
||||||
|
// framework says nothing while it does. A build that looks frozen
|
||||||
|
// is the single most-reported symptom of this command, so say out
|
||||||
|
// loud what the other likely explanation is rather than letting the
|
||||||
|
// operator guess.
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(60))
|
||||||
|
// Cancelling is how a *fast* load ends this task, and a
|
||||||
|
// cancelled sleep returns rather than throwing past `try?` — so
|
||||||
|
// without this the note prints on every quick failure, which is
|
||||||
|
// precisely when it is misleading.
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
progress?(
|
||||||
|
.note(
|
||||||
|
"still loading — a truncated or partially downloaded .ipsw can block here; "
|
||||||
|
+ "verify the download completed"))
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
restoreImage = try await provider.load(localPath: ipswPath)
|
restoreImage = try await provider.load(localPath: ipswPath)
|
||||||
} else {
|
} else {
|
||||||
progress?(.downloadingIPSW(fraction: 0))
|
progress?(.downloadingIPSW(fraction: 0))
|
||||||
@@ -100,8 +127,7 @@ public struct ImageBuilder: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2/3. Hardware model and bundle.
|
// 2/3. Hardware model and bundle.
|
||||||
progress?(.preparing)
|
progress?(.creatingBundle(diskGB: config.guest.diskGB))
|
||||||
progress?(.creatingBundle)
|
|
||||||
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
||||||
|
|
||||||
// 4. Install.
|
// 4. Install.
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ struct DaemonCommand: AsyncParsableCommand {
|
|||||||
// Expected on shutdown.
|
// Expected on shutdown.
|
||||||
} catch {
|
} catch {
|
||||||
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// 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`.
|
/// 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.
|
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
|
||||||
@MainActor
|
|
||||||
enum VZAppRuntime {
|
enum VZAppRuntime {
|
||||||
/// Signal sources have to outlive the call that creates them or they are
|
/// Signal sources have to outlive the call that creates them or they are
|
||||||
/// cancelled on deinit and the signals go nowhere.
|
/// cancelled on deinit and the signals go nowhere.
|
||||||
private static var signalSources: [DispatchSourceSignal] = []
|
private nonisolated(unsafe) static var signalSources: [DispatchSourceSignal] = []
|
||||||
private static var isTerminating = false
|
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.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
||||||
/// - body: The work to run. When it returns, the process exits zero.
|
/// - body: The work to run. When it returns, the process exits zero.
|
||||||
|
@MainActor
|
||||||
static func run(
|
static func run(
|
||||||
onSignal: @escaping @Sendable () async -> Void,
|
onSignal: @escaping @Sendable () async -> Void,
|
||||||
body: @escaping @Sendable () async -> Void
|
body: @escaping @Sendable () async -> Void
|
||||||
@@ -149,30 +172,48 @@ enum VZAppRuntime {
|
|||||||
// DispatchSourceSignal only observes; the default disposition still
|
// DispatchSourceSignal only observes; the default disposition still
|
||||||
// kills the process unless it is ignored first.
|
// kills the process unless it is ignored first.
|
||||||
signal(signalNumber, SIG_IGN)
|
signal(signalNumber, SIG_IGN)
|
||||||
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
|
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: signalQueue)
|
||||||
source.setEventHandler {
|
source.setEventHandler {
|
||||||
Task { @MainActor in
|
guard beginTerminating() else { return }
|
||||||
guard !isTerminating else { return }
|
Task.detached {
|
||||||
isTerminating = true
|
|
||||||
CLI.note("received signal; shutting down…")
|
CLI.note("received signal; shutting down…")
|
||||||
await onSignal()
|
await onSignal()
|
||||||
NSApp.terminate(nil)
|
flushAndExit(0)
|
||||||
exit(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source.resume()
|
source.resume()
|
||||||
|
stateLock.lock()
|
||||||
signalSources.append(source)
|
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 body()
|
||||||
await MainActor.run {
|
flushAndExit(0)
|
||||||
NSApp.terminate(nil)
|
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.run()
|
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,
|
name: imageName,
|
||||||
ipswPath: ipswPath,
|
ipswPath: ipswPath,
|
||||||
config: frozenConfig,
|
config: frozenConfig,
|
||||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
printer.finish()
|
printer.finish()
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
printer.finish("done")
|
printer.finish("done")
|
||||||
|
|
||||||
@@ -254,14 +255,15 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
name: imageName,
|
name: imageName,
|
||||||
config: frozenConfig,
|
config: frozenConfig,
|
||||||
xcodeXIPPath: xipPath,
|
xcodeXIPPath: xipPath,
|
||||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
printer.finish()
|
printer.finish()
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
printer.finish("done")
|
printer.finish("done")
|
||||||
print("provisioned image '\(imageName)'")
|
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.
|
/// Renders a build stage as one status line.
|
||||||
static func describe(_ stage: ImageBuildStage) -> String {
|
static func describe(_ stage: ImageBuildStage) -> String {
|
||||||
switch stage {
|
switch stage {
|
||||||
case .downloadingIPSW(let fraction):
|
case .downloadingIPSW(let fraction):
|
||||||
return "downloading IPSW " + CLI.progressBar(fraction)
|
return "downloading IPSW " + CLI.progressBar(fraction)
|
||||||
case .preparing:
|
case .preparing:
|
||||||
return "preparing"
|
return "resolving restore image…"
|
||||||
case .creatingBundle:
|
case .loadingRestoreImage:
|
||||||
return "creating bundle"
|
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):
|
case .installing(let fraction):
|
||||||
return "installing macOS " + CLI.progressBar(fraction)
|
return "installing macOS " + CLI.progressBar(fraction)
|
||||||
case .firstBoot:
|
case .firstBoot:
|
||||||
return "first boot (Setup Assistant)"
|
return "first boot + guest provisioning…"
|
||||||
case .provisioning(let step):
|
case .provisioning(let step):
|
||||||
return "provisioning: \(step)"
|
return "provisioning: \(step)"
|
||||||
case .finalizing:
|
case .finalizing:
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ struct VMCommand: AsyncParsableCommand {
|
|||||||
} catch {
|
} catch {
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
await session.teardown()
|
await session.teardown()
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// 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 let lock = NSLock()
|
||||||
private var lastLine = ""
|
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.
|
/// Rewrites the current line.
|
||||||
func update(_ line: String) {
|
func update(_ line: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
guard line != lastLine else { return }
|
guard line != lastLine else { return }
|
||||||
lastLine = line
|
lastLine = line
|
||||||
|
guard isInteractive else {
|
||||||
|
FileHandle.standardError.write(Data((line + "\n").utf8))
|
||||||
|
return
|
||||||
|
}
|
||||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||||
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
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.
|
/// Ends the line so subsequent output starts cleanly.
|
||||||
func finish(_ line: String? = nil) {
|
func finish(_ line: String? = nil) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if let line {
|
if let line {
|
||||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
let padding = isInteractive ? String(repeating: " ", count: max(0, 78 - line.count)) : ""
|
||||||
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
|
let prefix = isInteractive ? "\r" : ""
|
||||||
} else if !lastLine.isEmpty {
|
FileHandle.standardError.write(Data((prefix + line + padding + "\n").utf8))
|
||||||
|
} else if !lastLine.isEmpty, isInteractive {
|
||||||
FileHandle.standardError.write(Data("\n".utf8))
|
FileHandle.standardError.write(Data("\n".utf8))
|
||||||
}
|
}
|
||||||
lastLine = ""
|
lastLine = ""
|
||||||
|
|||||||
@@ -140,6 +140,106 @@ struct PathResolutionTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - IPSW pre-validation
|
||||||
|
|
||||||
|
/// Writes `bytes` at the front of a sparse file of `size` bytes.
|
||||||
|
///
|
||||||
|
/// Sparse because a valid-size fixture is a gigabyte and nobody should wait
|
||||||
|
/// for a gigabyte of zeroes to be written to test a two-byte check.
|
||||||
|
private func withIPSW(bytes: [UInt8], size: UInt64, _ body: (String) throws -> Void) throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gmr-ipsw-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let path = dir.appendingPathComponent("image.ipsw").path
|
||||||
|
#expect(FileManager.default.createFile(atPath: path, contents: Data(bytes)))
|
||||||
|
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
|
||||||
|
try handle.truncate(atOffset: size)
|
||||||
|
try handle.close()
|
||||||
|
|
||||||
|
try body(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let pk: [UInt8] = [0x50, 0x4B]
|
||||||
|
private let validSize: UInt64 = 2_000_000_000
|
||||||
|
|
||||||
|
@Test("a plausible restore image passes")
|
||||||
|
func healthyIPSWValidates() throws {
|
||||||
|
try withIPSW(bytes: pk, size: validSize) { path in
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a missing file is notFound, not a hang")
|
||||||
|
func missingIPSWThrows() throws {
|
||||||
|
#expect(throws: CoreError.self) {
|
||||||
|
try IPSWFile.validate(path: "/tmp/gmr-definitely-absent.ipsw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a directory is rejected before the framework sees it")
|
||||||
|
func directoryIsRejected() throws {
|
||||||
|
try withFiles([]) { dir in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: dir)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
#expect("\(error)".contains("directory"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a truncated download names its own size and says what happened")
|
||||||
|
func truncatedIPSWIsRejected() throws {
|
||||||
|
// 4 MB: the shape of a download that stopped early — right magic bytes,
|
||||||
|
// nowhere near the right length.
|
||||||
|
try withIPSW(bytes: pk, size: 4_000_000) { path in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .configInvalid(let message) = error else {
|
||||||
|
Issue.record("expected .configInvalid, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("3.8 MB"))
|
||||||
|
#expect(message.contains("incomplete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an empty file is rejected on size, before anything reads it")
|
||||||
|
func emptyIPSWIsRejected() throws {
|
||||||
|
try withIPSW(bytes: [], size: 0) { path in
|
||||||
|
#expect(throws: CoreError.self) { try IPSWFile.validate(path: path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a big file that is not a zip is rejected on its magic bytes")
|
||||||
|
func wrongMagicIsRejected() throws {
|
||||||
|
try withIPSW(bytes: [0x00, 0x01], size: validSize) { path in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .configInvalid(let message) = error else {
|
||||||
|
Issue.record("expected .configInvalid, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("PK"))
|
||||||
|
#expect(message.contains("0001"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("sizes are rendered the way the operator will compare them")
|
||||||
|
func sizeDescriptions() {
|
||||||
|
#expect(IPSWFile.describeSize(0) == "0 B")
|
||||||
|
#expect(IPSWFile.describeSize(512) == "512 B")
|
||||||
|
#expect(IPSWFile.describeSize(22_567_352_533) == "21.0 GB")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("the label names the offending flag so the operator knows what to fix")
|
@Test("the label names the offending flag so the operator knows what to fix")
|
||||||
func labelAppearsInErrors() throws {
|
func labelAppearsInErrors() throws {
|
||||||
try withFiles([]) { dir in
|
try withFiles([]) { dir in
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ gitea-macos-runner service status
|
|||||||
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
|
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
|
||||||
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
|
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
|
||||||
| `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild |
|
| `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild |
|
||||||
|
| `image build` prints its banner and then nothing, at 0% CPU | Old build: the build task was queued behind the `NSApplication` run loop and never started | Upgrade — the task is now detached. Stage lines should appear within seconds |
|
||||||
|
| `image build` stuck at `loading restore image metadata…` | A truncated or partial `.ipsw` — the framework blocks rather than failing | Upgrade (the file is now size- and magic-checked first); re-download the IPSW |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -363,6 +365,72 @@ concurrent clones diverging.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `image build` prints the banner and then nothing at all
|
||||||
|
|
||||||
|
**Symptom.** `image build` prints
|
||||||
|
|
||||||
|
```
|
||||||
|
building image 'default' (this takes a while; the IPSW alone is ~15 GB)
|
||||||
|
```
|
||||||
|
|
||||||
|
and then stops — no stage lines, no progress bar, no error. Activity Monitor shows the process
|
||||||
|
using no CPU and no VM services running. Ctrl-C does nothing.
|
||||||
|
|
||||||
|
**Cause.** A bug in releases before this fix. `image build` hosts an `NSApplication` run loop
|
||||||
|
(Virtualization.framework requires one), and the work was started with a `Task` that inherited the
|
||||||
|
main actor. Because `NSApplication.run()` is itself reached from Swift's async `main`, the main
|
||||||
|
dispatch queue already had a block in flight and would not re-enter — so the build task was queued
|
||||||
|
behind a run loop that never yields and never got a first tick. Nothing ran, including the code
|
||||||
|
that would have reported the error. `SIGINT`/`SIGTERM` handling was stuck the same way, which is
|
||||||
|
why Ctrl-C did not work either.
|
||||||
|
|
||||||
|
**Fix.** Upgrade. The build task is now detached and signals are handled off the main queue. You
|
||||||
|
should see stage lines within a second or two:
|
||||||
|
|
||||||
|
```
|
||||||
|
resolving restore image…
|
||||||
|
loading restore image metadata…
|
||||||
|
creating VM bundle (disk 64 GB)…
|
||||||
|
installing macOS [########----------------------] 27%
|
||||||
|
```
|
||||||
|
|
||||||
|
If a build still goes quiet, the line last printed tells you which stage owns the silence — see
|
||||||
|
below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `image build` sits at "loading restore image metadata…"
|
||||||
|
|
||||||
|
**Symptom.** The build reaches `loading restore image metadata…` and stays there. After a minute it
|
||||||
|
adds:
|
||||||
|
|
||||||
|
```
|
||||||
|
still loading — a truncated or partially downloaded .ipsw can block here; verify the download completed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause.** `VZMacOSRestoreImage.image(from:)` reads the whole archive's metadata and reports no
|
||||||
|
progress while it does. On a healthy ~21 GB IPSW this takes seconds to a minute or two. On a
|
||||||
|
*partial* download it can block for a very long time instead of failing.
|
||||||
|
|
||||||
|
**Fix.** The obvious checks are now made before the framework is handed the file — it must exist, be
|
||||||
|
a regular file, be at least 1 GB, and start with the zip magic `PK` — so an incomplete download now
|
||||||
|
fails immediately with its actual size rather than hanging. If you are on an older build, check by
|
||||||
|
hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -la ~/Downloads/UniversalMac_*.ipsw # ~15-22 GB, no .download/.crdownload sibling
|
||||||
|
xxd -l 2 -p ~/Downloads/UniversalMac_*.ipsw # must print 504b
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything smaller, or not starting `504b`, is an incomplete or wrong file: delete it and download it
|
||||||
|
again.
|
||||||
|
|
||||||
|
> A pattern that matches **more than one** IPSW is refused outright, listing the matches — for
|
||||||
|
> example `~/Downloads/UniversalMac_27.0_*.ipsw` when two 27.0 builds are sitting in `~/Downloads`.
|
||||||
|
> Name exactly one of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `image build` hangs at install
|
## `image build` hangs at install
|
||||||
|
|
||||||
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
|
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
|
||||||
|
|||||||
Reference in New Issue
Block a user