diff --git a/Sources/RunnerCore/PathResolution.swift b/Sources/RunnerCore/PathResolution.swift index e230db7..c85e847 100644 --- a/Sources/RunnerCore/PathResolution.swift +++ b/Sources/RunnerCore/PathResolution.swift @@ -108,3 +108,80 @@ public enum PathResolution { #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]) + } +} diff --git a/Sources/RunnerHost/IPSW.swift b/Sources/RunnerHost/IPSW.swift index d272d83..58fafc1 100644 --- a/Sources/RunnerHost/IPSW.swift +++ b/Sources/RunnerHost/IPSW.swift @@ -155,12 +155,10 @@ public struct IPSWProvider: Sendable { // 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 // politeness; it is the only thing standing between a typo and a crash. - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), - !isDirectory.boolValue - else { - throw CoreError.notFound("restore image not found at \(url.path)") - } + // The size and magic-byte checks alongside it cover the other way this + // call goes wrong: handed a partial download it neither fails nor + // reports progress, it simply stops responding. + try IPSWFile.validate(path: url.path) do { return try await VZMacOSRestoreImage.image(from: url) diff --git a/Sources/RunnerHost/ImageBuilder.swift b/Sources/RunnerHost/ImageBuilder.swift index b2e3c61..8d5f521 100644 --- a/Sources/RunnerHost/ImageBuilder.swift +++ b/Sources/RunnerHost/ImageBuilder.swift @@ -6,10 +6,16 @@ import Virtualization public enum ImageBuildStage: Sendable, Equatable { /// Downloading the IPSW. 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 + /// 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. - 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. case installing(fraction: Double) /// 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 restoreImage: VZMacOSRestoreImage 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) } else { progress?(.downloadingIPSW(fraction: 0)) @@ -100,8 +127,7 @@ public struct ImageBuilder: Sendable { } // 2/3. Hardware model and bundle. - progress?(.preparing) - progress?(.creatingBundle) + progress?(.creatingBundle(diskGB: config.guest.diskGB)) let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config) // 4. Install. diff --git a/Sources/gitea-macos-runner/CommandDaemon.swift b/Sources/gitea-macos-runner/CommandDaemon.swift index 04b0c48..40a0398 100644 --- a/Sources/gitea-macos-runner/CommandDaemon.swift +++ b/Sources/gitea-macos-runner/CommandDaemon.swift @@ -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) } } diff --git a/Sources/gitea-macos-runner/CommandImage.swift b/Sources/gitea-macos-runner/CommandImage.swift index b681fa8..a87538b 100644 --- a/Sources/gitea-macos-runner/CommandImage.swift +++ b/Sources/gitea-macos-runner/CommandImage.swift @@ -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: diff --git a/Sources/gitea-macos-runner/CommandVM.swift b/Sources/gitea-macos-runner/CommandVM.swift index 4616cc9..65e2dfb 100644 --- a/Sources/gitea-macos-runner/CommandVM.swift +++ b/Sources/gitea-macos-runner/CommandVM.swift @@ -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) } } ) diff --git a/Sources/gitea-macos-runner/Main.swift b/Sources/gitea-macos-runner/Main.swift index b251c79..3c853a9 100644 --- a/Sources/gitea-macos-runner/Main.swift +++ b/Sources/gitea-macos-runner/Main.swift @@ -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 = "" diff --git a/Tests/RunnerCoreTests/PathResolutionTests.swift b/Tests/RunnerCoreTests/PathResolutionTests.swift index 343ad2f..3ecad2b 100644 --- a/Tests/RunnerCoreTests/PathResolutionTests.swift +++ b/Tests/RunnerCoreTests/PathResolutionTests.swift @@ -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") func labelAppearsInErrors() throws { try withFiles([]) { dir in diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ee04d6..696e968 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 | | 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` 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 **Symptom.** `image build` sits for a long time at the macOS install phase with little visible