diff --git a/Development/Simulation.md b/Development/Simulation.md index a4ce7f8..caaff57 100644 --- a/Development/Simulation.md +++ b/Development/Simulation.md @@ -33,6 +33,7 @@ Requires Xcode and XcodeBuildMCP. The launcher builds the debug app and supplies | Authorization | Missing helper; plan changes before approval; first credentials rejected | Respect the gate; permit back-navigation before submission; rejected dummy credentials can be retried | | Execution | Connection lost; empty reply; helper failure; interrupted live progress | Retain verified activity; uncertain outcomes cannot start another installation | | Engine refusal | Space shrinks before install | The engine refuses the approved 137 GB plan before any disk step; the page says no disk changes were made and offers **Check available space**, which returns to review with 133 GB, a size-change notice and a cleared acknowledgement; the next install succeeds | +| Prepared resume | Prepared resume target changed | Explain earlier disk preparation, retain the saved checkpoint in Last verified activity, preserve the journal, and require reconciliation without offering a fresh install or blind retry | | Recovery | Recovery fails then retry succeeds; manual recovery required; shutdown request fails | Retry only the eligible Recovery path; keep instructions visible when shutdown fails | Click the size field to edit GB. Use the green checkmark or Return to apply; use the red cross or Escape to cancel. Invalid or out-of-range values cannot be applied. Installation is disabled until you apply or cancel. diff --git a/Sources/OmarchyAppleInstaller/ClosedEngineHandoffProcess.swift b/Sources/OmarchyAppleInstaller/ClosedEngineHandoffProcess.swift index 6ae8baa..f004267 100644 --- a/Sources/OmarchyAppleInstaller/ClosedEngineHandoffProcess.swift +++ b/Sources/OmarchyAppleInstaller/ClosedEngineHandoffProcess.swift @@ -53,12 +53,17 @@ } public func execute(_ invocation: ClosedEngineInvocation) async throws -> Data { - let handoff = try InstallerPerformance.measure("app_handoff") { - try ClosedEngineHandoffBuilder().prepare( - invocation: invocation, - assets: assets, - in: handoffDirectory - ) + let handoff: PreparedEngineHandoff + do { + handoff = try InstallerPerformance.measure("app_handoff") { + try ClosedEngineHandoffBuilder().prepare( + invocation: invocation, + assets: assets, + in: handoffDirectory + ) + } + } catch { + throw InstallerPreSubmissionFailure(error) } defer { try? FileManager.default.removeItem(at: handoff.packageURL) } return try await submitter.submit( diff --git a/Sources/OmarchyAppleInstaller/EngineFailureDiagnostics.swift b/Sources/OmarchyAppleInstaller/EngineFailureDiagnostics.swift index d29632e..6abbb48 100644 --- a/Sources/OmarchyAppleInstaller/EngineFailureDiagnostics.swift +++ b/Sources/OmarchyAppleInstaller/EngineFailureDiagnostics.swift @@ -21,8 +21,10 @@ case deviceUnsupported = 3 /// The plan, request, or identity failed an integrity check. case planIntegrity = 4 + /// A resumed run could not reconcile its checkpointed APFS target. + case preparedResumeMismatch = 5 - /// Exact final-exception messages raised before the first mutation by + /// Exact final-exception messages raised by admission or resume checks in /// `omarchy_execution.admit_execution`, `omarchy_runtime` and the Asahi /// adapter preflight. Anything not listed stays unclassified. static func classify(exception: String, message: String) -> EngineFailureReason { @@ -37,6 +39,8 @@ "approved candidate is unavailable", "system store changed during resume", "inventory changed during resume": return .diskLayoutChanged + case "prepared resume target does not match checkpoint": + return .preparedResumeMismatch case "device is explicitly unsupported": return .deviceUnsupported case "plan digest mismatch", "helper environment binding mismatch", diff --git a/Sources/OmarchyAppleInstaller/InstallerExecutionCoordinator.swift b/Sources/OmarchyAppleInstaller/InstallerExecutionCoordinator.swift index 8f2e021..4a7fedf 100644 --- a/Sources/OmarchyAppleInstaller/InstallerExecutionCoordinator.swift +++ b/Sources/OmarchyAppleInstaller/InstallerExecutionCoordinator.swift @@ -1,10 +1,28 @@ #if os(macOS) import Foundation + /// Created only by app-side checks that fail before an execution request is sent. + /// A transport failure after submission must never be wrapped in this type. + public struct InstallerPreSubmissionFailure: Error, Sendable { + public let underlying: any Error + + public init(_ underlying: any Error) { + self.underlying = underlying + } + } + public struct InstallerExecutionCoordinator: Sendable { private let processAdapter = ClosedEngineProcessAdapter() - public init() {} + private let ping: @Sendable (AuthenticatedEngineXPCSubmitter) async throws -> Void + + public init() { + ping = { try await $0.ping() } + } + + init(ping: @escaping @Sendable (AuthenticatedEngineXPCSubmitter) async throws -> Void) { + self.ping = ping + } public func execute( _ prepared: PreparedInstallerPlanExecution, @@ -53,14 +71,18 @@ operation: EngineHandoffOperation, journalProgress: (@Sendable (Data) -> Void)? ) async throws -> InstallerExecutionProgress { - let submitter = try AuthenticatedEngineXPCSubmitter( - machServiceName: configuration.helperMachServiceName, - helperCodeSigningRequirement: - configuration.helperCodeSigningRequirement, - journalProgress: journalProgress - ) - // Nothing leaves the app until the helper has answered. - try await submitter.ping() + let submitter: AuthenticatedEngineXPCSubmitter + do { + submitter = try AuthenticatedEngineXPCSubmitter( + machServiceName: configuration.helperMachServiceName, + helperCodeSigningRequirement: configuration.helperCodeSigningRequirement, + journalProgress: journalProgress + ) + // Ping sends no execution request, handoff or credentials. + try await ping(submitter) + } catch { + throw InstallerPreSubmissionFailure(error) + } let process = ClosedEngineHandoffProcess( assets: prepared.review.assets, handoffDirectory: handoffDirectory, diff --git a/Sources/OmarchyAppleInstaller/PayloadPrefetch.swift b/Sources/OmarchyAppleInstaller/PayloadPrefetch.swift index 807c07f..35de7f2 100644 --- a/Sources/OmarchyAppleInstaller/PayloadPrefetch.swift +++ b/Sources/OmarchyAppleInstaller/PayloadPrefetch.swift @@ -345,20 +345,30 @@ work: @escaping @Sendable () async throws -> Void ) async throws { let stream = network.updates() - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await work() } + try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + try await work() + try Task.checkCancellation() + return true + } group.addTask { for await path in stream { if Task.isCancelled { - return + return false } if !path.allowsPrefetch { throw PayloadPrefetchError.meteredNetwork } } + return false } do { - try await group.next() + // A stopped path observer is not proof that the file was staged. + // Only the work task can establish successful verification. + while let workFinished = try await group.next() { + if workFinished { break } + } + try Task.checkCancellation() group.cancelAll() } catch { group.cancelAll() @@ -531,7 +541,8 @@ private var generation = UUID() private var controller: PayloadPrefetchController? - private var runningDigest: String? + private var runningArtifact: PinnedInstallerArtifact? + private var runningDestination: URL? private var latest: PayloadPrefetchState = .idle private var observers: [UUID: @Sendable (PayloadPrefetchState) -> Void] = [:] private var waiters: [UUID: CheckedContinuation] = [:] @@ -581,7 +592,9 @@ let parent = canonical.deletingLastPathComponent() let reuse = lock.withLock { () -> Bool in - if runningDigest == artifact.expectedDigest { + if runningArtifact == artifact, + runningDestination == canonical.standardizedFileURL + { switch latest { case .failed, .cancelled: return false @@ -600,7 +613,8 @@ lock.lock() predecessor = controller controller = nil - runningDigest = artifact.expectedDigest + runningArtifact = artifact + runningDestination = canonical.standardizedFileURL generation = UUID() self.generation = generation latest = .idle @@ -683,7 +697,11 @@ } } } - try self.promote(staged.fileURL, to: canonical) + try Task.checkCancellation() + try self.lock.withLock { + guard self.generation == generation else { throw CancellationError() } + try self.promote(staged.fileURL, to: canonical) + } try? FileManager.default.removeItem(at: work) } } @@ -767,7 +785,8 @@ public func cancel() { lock.lock() latest = .idle - runningDigest = nil + runningArtifact = nil + runningDestination = nil cancelEpoch &+= 1 let existing = controller controller = nil diff --git a/Sources/OmarchyAppleInstaller/VerifiedArtifactStager.swift b/Sources/OmarchyAppleInstaller/VerifiedArtifactStager.swift index 3411589..2c86754 100644 --- a/Sources/OmarchyAppleInstaller/VerifiedArtifactStager.swift +++ b/Sources/OmarchyAppleInstaller/VerifiedArtifactStager.swift @@ -281,6 +281,7 @@ public struct VerifiedArtifactStager: Sendable { in stagingDirectory: URL, progress: ArtifactStagingProgressHandler? = nil ) async throws -> StagedInstallerArtifact { + try Task.checkCancellation() let fileManager = FileManager.default try ensureSafeDirectory(stagingDirectory, fileManager: fileManager) let destination = stagingDirectory.appendingPathComponent( @@ -291,6 +292,8 @@ public struct VerifiedArtifactStager: Sendable { if fileManager.fileExists(atPath: destination.path) { do { try verify(artifact, at: destination) + } catch is CancellationError { + throw CancellationError() } catch { throw ArtifactStageError.destinationConflict(artifact.fileName) } @@ -320,6 +323,7 @@ public struct VerifiedArtifactStager: Sendable { try fileManager.copyItem(at: source, to: pending) try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path) try verify(artifact, at: pending) + try Task.checkCancellation() try fileManager.moveItem(at: pending, to: destination) report( progress, artifact: artifact, phase: .verified, bytesCompleted: artifact.expectedSizeBytes) @@ -373,6 +377,7 @@ public struct VerifiedArtifactStager: Sendable { destination: URL, progress: ArtifactStagingProgressHandler? ) async throws -> StagedInstallerArtifact { + try Task.checkCancellation() let fileManager = FileManager.default var partFileURLs = [URL]() var completedBytes: UInt64 = 0 @@ -424,6 +429,7 @@ public struct VerifiedArtifactStager: Sendable { expectedSizeBytes: artifact.expectedSizeBytes ) + try Task.checkCancellation() do { try fileManager.moveItem(at: pending, to: destination) } catch { @@ -432,6 +438,8 @@ public struct VerifiedArtifactStager: Sendable { } do { try verify(artifact, at: destination) + } catch is CancellationError { + throw CancellationError() } catch { throw ArtifactStageError.destinationConflict(artifact.fileName) } @@ -479,6 +487,8 @@ public struct VerifiedArtifactStager: Sendable { expectedSizeBytes: expectedSizeBytes, at: destination ) + } catch is CancellationError { + throw CancellationError() } catch { throw ArtifactStageError.destinationConflict(fileName) } @@ -498,6 +508,7 @@ public struct VerifiedArtifactStager: Sendable { ) defer { try? fileManager.removeItem(at: pending) } + try Task.checkCancellation() let materialization = try promoter.promote( source: downloaded, pending: pending @@ -509,6 +520,7 @@ public struct VerifiedArtifactStager: Sendable { at: pending ) + try Task.checkCancellation() do { try fileManager.moveItem(at: pending, to: destination) } catch { @@ -522,6 +534,8 @@ public struct VerifiedArtifactStager: Sendable { expectedSizeBytes: expectedSizeBytes, at: destination ) + } catch is CancellationError { + throw CancellationError() } catch { throw ArtifactStageError.destinationConflict(fileName) } @@ -536,6 +550,7 @@ public struct VerifiedArtifactStager: Sendable { expectedDigest: String, expectedSizeBytes: UInt64 ) throws { + try Task.checkCancellation() let fileManager = FileManager.default guard fileManager.createFile( @@ -558,6 +573,7 @@ public struct VerifiedArtifactStager: Sendable { while let chunk = try reader.read(upToCount: Self.chunkBytes), !chunk.isEmpty { + try Task.checkCancellation() try writer.write(contentsOf: chunk) hasher.update(data: chunk) let total = size.addingReportingOverflow(UInt64(chunk.count)) @@ -732,6 +748,7 @@ public struct VerifiedArtifactStager: Sendable { private func digestAndSize( of fileURL: URL ) throws -> (digest: String, size: UInt64) { + try Task.checkCancellation() let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } var hasher = SHA256() @@ -740,6 +757,7 @@ public struct VerifiedArtifactStager: Sendable { while let chunk = try handle.read(upToCount: Self.chunkBytes), !chunk.isEmpty { + try Task.checkCancellation() let addition = UInt64(chunk.count) guard !size.addingReportingOverflow(addition).overflow else { throw ArtifactStageError.invalidExpectedSize diff --git a/Sources/OmarchyAppleInstallerApp/InstallerComponents.swift b/Sources/OmarchyAppleInstallerApp/InstallerComponents.swift index 609c3e1..f0a277a 100644 --- a/Sources/OmarchyAppleInstallerApp/InstallerComponents.swift +++ b/Sources/OmarchyAppleInstallerApp/InstallerComponents.swift @@ -194,21 +194,29 @@ struct ProgressTrack: View { Capsule() .fill(OmarchyTheme.accent) .frame(width: geometry.size.width * min(1, max(0, fraction))) - .animation(.linear(duration: 0.18), value: fraction) + .animation(reduceMotion ? nil : .linear(duration: 0.18), value: fraction) } else { Capsule() .fill(OmarchyTheme.accent.opacity(0.75)) .frame(width: geometry.size.width * 0.32) .offset(x: sweep ? geometry.size.width * 0.68 : 0) .animation( - .easeInOut(duration: 1.1).repeatForever(autoreverses: true), + reduceMotion ? nil : .easeInOut(duration: 1.1).repeatForever(autoreverses: true), value: sweep ) .onAppear { sweep = !reduceMotion } + .onChange(of: reduceMotion) { _, reduced in sweep = !reduced } } } } .frame(height: height) + .accessibilityRepresentation { + if let fraction { + ProgressView("Progress", value: min(1, max(0, fraction))) + } else { + ProgressView("In progress") + } + } } } diff --git a/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift b/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift index aaf10d7..cab95c4 100644 --- a/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift +++ b/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift @@ -492,12 +492,13 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable journal: @escaping @Sendable (Data) -> Void ) async throws -> CompletionDisplay { guard buildProfile.allowsEncryption || !encryptLinuxDisk else { - throw NSError( - domain: "OmarchyPrivateTest", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "This private M3 test supports plain installation only." - ] - ) + throw InstallerPreSubmissionFailure( + NSError( + domain: "OmarchyPrivateTest", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "This private M3 test supports plain installation only." + ] + )) } let executionStarted = ProcessInfo.processInfo.systemUptime let (prepared, approval, configuration, host) = lock.withLock { @@ -505,19 +506,22 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable } guard let prepared, let approval, let configuration, let host else { - throw InstallerAppError.approvalUnavailable + throw InstallerPreSubmissionFailure(InstallerAppError.approvalUnavailable) } // Re-inspection identity match: the Mac that is about to be written to // must still be the Mac the plan was bound to. - let currentHost = try AppleSiliconHostInspector().inspect() - guard - currentHost.identity.deviceIdentifier == host.identity.deviceIdentifier - else { - throw InstallerAppError.hostChanged + let handoffDirectory: URL + do { + let currentHost = try AppleSiliconHostInspector().inspect() + guard currentHost.identity.deviceIdentifier == host.identity.deviceIdentifier else { + throw InstallerAppError.hostChanged + } + handoffDirectory = try installerWorkspace().handoff + } catch { + throw InstallerPreSubmissionFailure(error) } - let workspace = try installerWorkspace() let coordinator = InstallerExecutionCoordinator() let progress: InstallerExecutionProgress do { @@ -527,7 +531,7 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable prepared, approval: approval, configuration: configuration, - handoffDirectory: workspace.handoff, + handoffDirectory: handoffDirectory, machineOwnerAuthorization: authorization, journalProgress: journal ) @@ -536,7 +540,7 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable prepared, approval: approval, configuration: configuration, - handoffDirectory: workspace.handoff, + handoffDirectory: handoffDirectory, machineOwnerAuthorization: authorization, journalProgress: journal ) diff --git a/Sources/OmarchyAppleInstallerApp/OmarchyAppleInstallerApp.swift b/Sources/OmarchyAppleInstallerApp/OmarchyAppleInstallerApp.swift index 29bc600..728968c 100644 --- a/Sources/OmarchyAppleInstallerApp/OmarchyAppleInstallerApp.swift +++ b/Sources/OmarchyAppleInstallerApp/OmarchyAppleInstallerApp.swift @@ -6,9 +6,10 @@ import SwiftUI @MainActor private final class InstallerApplicationDelegate: NSObject, NSApplicationDelegate { static var removalInProgress = false + static weak var session: InstallerSession? func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - if Self.removalInProgress { + if Self.removalInProgress || Self.session?.isExecutionInProgress == true { NSSound.beep() return .terminateCancel } @@ -70,6 +71,7 @@ struct OmarchyAppleInstallerApp: App { /// nothing planned against one channel is installed from the other. @State private var liveSession: InstallerSession? @State private var showsRemoval = false + @State private var removalInProgress = false @State private var removalNeedsReview = false @State private var simulationDark = true @State private var generation = UUID() @@ -87,7 +89,7 @@ struct OmarchyAppleInstallerApp: App { #if DEBUG if isSimulation { SimulationDashboard( - onSessionAvailable: { liveSession = $0 }, onColorSchemeChange: { simulationDark = $0 }) + onSessionAvailable: rememberSession, onColorSchemeChange: { simulationDark = $0 }) } else { liveContent } @@ -104,13 +106,18 @@ struct OmarchyAppleInstallerApp: App { private var liveContent: some View { OnePageInstallerView( environment: InstallerEnvironmentFactory.make(), channel: channel, - onSessionAvailable: { liveSession = $0 }) + onSessionAvailable: rememberSession) + } + + private func rememberSession(_ session: InstallerSession) { + liveSession = session + InstallerApplicationDelegate.session = session } @State private var channel = ReleaseChannelPreference().resolveFromMainBundle() var body: some Scene { - WindowGroup(PlainLanguage.windowTitle) { + Window(PlainLanguage.windowTitle, id: "installer") { Group { if removalNeedsReview { VStack { @@ -139,14 +146,15 @@ struct OmarchyAppleInstallerApp: App { liveSession?.cancelPrefetchOnQuit() } .disabled(showsRemoval) + .windowDismissBehavior( + removalInProgress || liveSession?.isExecutionInProgress == true ? .disabled : .enabled + ) .sheet(isPresented: $showsRemoval, onDismiss: { generation = UUID() }) { OmarchyRemovalSheet( isSimulation: isSimulation, onBusyChanged: { busy in + removalInProgress = busy InstallerApplicationDelegate.removalInProgress = busy - for window in NSApp.windows where window.sheetParent == nil { - window.standardWindowButton(.closeButton)?.isEnabled = !busy - } }, onClose: { showsRemoval = false }, onRequiresReview: { removalNeedsReview = true }) } .preferredColorScheme(isSimulation ? (simulationDark ? .dark : .light) : nil) diff --git a/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift b/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift index b4f649c..bb8a96d 100644 --- a/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift +++ b/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift @@ -210,6 +210,7 @@ struct OnePageInstallerView: View { DownloadPanel(update: update) case .planReview(let plan, let acknowledged): + planIdentity(plan) DiskSplitPanel( plan: plan, editable: plan.isResizable, @@ -242,6 +243,7 @@ struct OnePageInstallerView: View { acknowledgement(acknowledged, resizesMacOS: plan.fixedMacOSBytes == nil) case .awaitingInstall(let plan, let helper, _): + planIdentity(plan) DiskSplitPanel(plan: plan, editable: false, isBusy: false, onSizeChosen: { _ in }) PrefetchStrip( state: session.prefetchState, @@ -507,6 +509,16 @@ struct OnePageInstallerView: View { ForEach(handoff.steps) { step in RecoveryStepRow(step: step) } + Button("Copy setup steps") { + let steps = handoff.steps.map { step in + "\(step.number). \(step.title)" + (step.detail.map { "\n\($0)" } ?? "") + } + let text = ([handoff.headline] + [handoff.warning].compactMap { $0 } + steps) + .joined(separator: "\n\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .omarchySecondaryButton() if let message = session.shutdownMessage { Text(message).font(OmarchyTheme.body).foregroundStyle(OmarchyTheme.caution) } @@ -536,6 +548,16 @@ struct OnePageInstallerView: View { // MARK: Plumbing + private func planIdentity(_ plan: PlanDisplay) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(plan.releaseDescription) + Text(plan.targetDescription) + } + .font(OmarchyTheme.body) + .foregroundStyle(OmarchyTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + private var approvedSize: String? { guard case .awaitingInstall(let plan, _, _) = session.phase else { return nil } return PlainLanguage.bytes(plan.omarchyBytes) diff --git a/Sources/OmarchyInstallerUXCore/InstallerSession.swift b/Sources/OmarchyInstallerUXCore/InstallerSession.swift index 8a5e4a1..cfbb884 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerSession.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerSession.swift @@ -75,6 +75,10 @@ } public var isSimulation: Bool { environment.isSimulation } + /// Covers helper credential verification, execution, and final app handoff. + /// Waiting for the payload never prevents closing or quitting the app. + /// Unlike hasExecutionStarted, it clears on terminal outcomes so shutdown works. + public private(set) var isExecutionInProgress = false public var canInspect: Bool { !isBusy && !isExecuting && !hasExecutionStarted } public var canChangeChannel: Bool { canInspect && !isEditingSize && credentialSheet.context == nil @@ -542,17 +546,29 @@ journal.reset() installStartedAt = Date() isExecuting = true - defer { isExecuting = false } + defer { + isExecuting = false + isExecutionInProgress = false + } do { // Helper setup comes first: it checks the credentials in the app and // installs or replaces the helper, before anything is submitted. + isExecutionInProgress = true try await environment.ensureHelper( authorization, reenablingSwitchedOff: context.helperSwitchedOff) - if prefetchState != .verified { + isExecutionInProgress = false + do { + // The environment is authoritative; a verified payload returns immediately. try await environment.waitUntilPayloadVerified() prefetchState = .verified + } catch { + throw InstallerPreSubmissionFailure(error) } + guard operationID == currentOperation else { + throw InstallerPreSubmissionFailure(PayloadPrefetchError.cancelled) + } + isExecutionInProgress = true let completion = try await environment.execute( operation: context.kind, authorization: authorization, @@ -594,6 +610,17 @@ plan: PlanDisplay?, helper: HelperDisplay ) { + if error is InstallerPreSubmissionFailure { + if context.kind == .install { + hasExecutionStarted = false + environment.discardApproval() + recoveryRetryAvailable = false + } + retrySheet = .hidden + phase = .failed( + PlainLanguage.failure(for: error, retryRecoveryAvailable: recoveryRetryAvailable)) + return + } if let submission = error as? EngineXPCSubmissionError, submission == .machineOwnerCredentialsRejected { @@ -732,6 +759,9 @@ } public func cancelPrefetchOnQuit() { + if isExecuting && !isExecutionInProgress { + operationID = UUID() + } environment.cancelPayloadPrefetch() if prefetchState != .verified { prefetchState = .cancelled diff --git a/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift b/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift index ee9d044..5c32074 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift @@ -9,7 +9,7 @@ case noMacRelease, modelNotOnChannel, channelUnreachable case allocationClamped, allocationAligned, approvalChanged, credentialsRejected, connectionLost case emptyReply, helperFailure, degradedProgress, recoveryRetry, manualRecovery - case shutdownFailure, completed, installationMedia, spaceChanged + case shutdownFailure, completed, installationMedia, spaceChanged, preparedResumeMismatch public var id: String { rawValue } public var title: String { @@ -45,6 +45,7 @@ case .completed: "Installation complete → first-boot check" case .installationMedia: "Installation media required" case .spaceChanged: "Space shrinks before install · engine refuses plan" + case .preparedResumeMismatch: "Prepared resume target changed" } } @@ -60,6 +61,8 @@ "Approve the plan. Install must remain unavailable. Use Edit disk size to return to review." case .credentialsRejected: "Submit the test account. The first attempt is rejected; the next succeeds." + case .preparedResumeMismatch: + "The saved APFS target no longer matches. Confirm the failure explains prior preparation, preserves Last verified activity, and offers no fresh installation or blind retry." case .connectionLost, .emptyReply, .helperFailure: "Check the last verified activity. Starting another installation must remain unavailable." case .recoveryRetry: @@ -286,6 +289,14 @@ for (index, line) in Self.journalLines.enumerated() { try await tick() journal(scenario == .degradedProgress && index == 5 ? Data("broken\n".utf8) : line) + if index == 4 && scenario == .preparedResumeMismatch { + throw EngineXPCSubmissionError.engineFailed( + EngineFailureNotice( + reason: .preparedResumeMismatch, exitStatus: 1, diskUnchanged: false, + summary: + "prepared resume target does not match checkpoint") + ) + } if index == 5 { if scenario == .connectionLost { throw EngineXPCSubmissionError.connectionFailed } if scenario == .helperFailure { diff --git a/Sources/OmarchyInstallerUXCore/PlainLanguage.swift b/Sources/OmarchyInstallerUXCore/PlainLanguage.swift index 3cf3392..1e65788 100644 --- a/Sources/OmarchyInstallerUXCore/PlainLanguage.swift +++ b/Sources/OmarchyInstallerUXCore/PlainLanguage.swift @@ -474,6 +474,16 @@ : "Save the error details and check the verified installation record before trying again. \(engineDiagnosticsLocation)", replanAvailable: unchanged ) + case .preparedResumeMismatch: + return FailureDisplay( + headline: "The prepared installation no longer matches its saved checkpoint", + plainDetail: unchanged + ? "The current disk no longer matches the saved checkpoint. No disk changes were made." + : "The installation already prepared disk space for Omarchy. That disk space no longer matches the saved checkpoint. No further installation step was started by this resume attempt; earlier disk changes are not undone.", + technicalDetail: technicalDetail, + remedy: + "Keep the installation journal and copy the error details. Review the saved checkpoints to see whether file installation also completed. Restore the expected disk state or get help reconciling it before resuming; do not delete the journal or start a fresh install over it. \(engineDiagnosticsLocation)" + ) case .deviceUnsupported: return FailureDisplay( headline: blockedHeadline, @@ -537,6 +547,21 @@ ) -> FailureDisplay { let technical = String(describing: error) + if let preflight = error as? InstallerPreSubmissionFailure, + let submission = preflight.underlying as? EngineXPCSubmissionError, + submission == .helperUnresponsive || submission == .connectionFailed + { + return FailureDisplay( + headline: "The installation service isn’t responding", + plainDetail: retryRecoveryAvailable + ? "No new installation request was sent. Your verified installation checkpoint is preserved." + : "The app couldn’t get a response from the installation service. Installation has not started.", + technicalDetail: String(describing: preflight.underlying), + remedy: "Run the downloaded \(installerPackage) again, then reopen this app.", + retryRecoveryAvailable: retryRecoveryAvailable + ) + } + if retryRecoveryAvailable { return FailureDisplay( headline: "Recovery authorization didn’t complete", @@ -549,6 +574,17 @@ ) } + if let preflight = error as? InstallerPreSubmissionFailure { + return FailureDisplay( + headline: "Installation couldn’t start", + plainDetail: + "This attempt stopped before an installation request was sent. It made no disk changes.", + technicalDetail: String(describing: preflight.underlying), + remedy: + "Check the error details, then choose Check again to prepare and review a fresh plan." + ) + } + if let submission = error as? EngineXPCSubmissionError { switch submission { case .machineOwnerCredentialsRejected: diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHandoffProcessTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHandoffProcessTests.swift index e9d78e0..185dbeb 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHandoffProcessTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHandoffProcessTests.swift @@ -89,7 +89,7 @@ ) ) { XCTAssertEqual( - $0 as? ClosedEngineHandoffError, + ($0 as? InstallerPreSubmissionFailure)?.underlying as? ClosedEngineHandoffError, .artifactDigestMismatch("engine") ) } @@ -209,7 +209,7 @@ ) ) { XCTAssertEqual( - $0 as? ClosedEngineHandoffError, + ($0 as? InstallerPreSubmissionFailure)?.underlying as? ClosedEngineHandoffError, .assetBindingMismatch ) } @@ -272,7 +272,7 @@ ) ) { XCTAssertEqual( - $0 as? ClosedEngineHandoffError, + ($0 as? InstallerPreSubmissionFailure)?.underlying as? ClosedEngineHandoffError, .unsafeArtifact("payload") ) } diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/EngineFailureDiagnosticsTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/EngineFailureDiagnosticsTests.swift index 84a1036..5499783 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/EngineFailureDiagnosticsTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/EngineFailureDiagnosticsTests.swift @@ -174,6 +174,18 @@ .unclassified) } + func testPreparedResumeMismatchHasItsOwnDiagnostic() { + XCTAssertEqual( + EngineFailureReason.classify( + exception: "AsahiAdapterError", + message: "prepared resume target does not match checkpoint" + ).rawValue, 5) + XCTAssertEqual( + EngineFailureReason.classify( + exception: "ValueError", + message: "prepared resume target does not match checkpoint"), .unclassified) + } + func testSummaryIsOneBoundedPrintableLine() { let notice = EngineFailureNotice( reason: .unclassified, exitStatus: 1, diskUnchanged: false, diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerPlanReviewTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerPlanReviewTests.swift index 28c1c82..4c3f9a4 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerPlanReviewTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerPlanReviewTests.swift @@ -154,6 +154,29 @@ process: process ) + do { + _ = try await InstallerExecutionCoordinator(ping: { _ in + throw EngineXPCSubmissionError.helperUnresponsive + }).execute( + prepared, approval: approval, configuration: base.configuration, + handoffDirectory: URL(fileURLWithPath: "/private/tmp"), + machineOwnerAuthorization: MachineOwnerAuthorization( + username: "fixture", password: Data("fixture-password".utf8))) + XCTFail("Expected ping failure") + } catch { + XCTAssertEqual( + (error as? InstallerPreSubmissionFailure)?.underlying as? EngineXPCSubmissionError, + .helperUnresponsive) + } + do { + _ = try await InstallerExecutionCoordinator().execute( + prepared, approval: approval, process: FailingEngineProcess()) + XCTFail("Expected execute failure") + } catch { + XCTAssertFalse(error is InstallerPreSubmissionFailure) + XCTAssertEqual(error as? EngineXPCSubmissionError, .connectionFailed) + } + XCTAssertEqual(progress.nextAction, .enterRecovery) let capturedInvocation = await process.capturedInvocation() XCTAssertEqual( @@ -401,6 +424,12 @@ } } + private struct FailingEngineProcess: EngineProcessExecuting { + func execute(_ invocation: ClosedEngineInvocation) async throws -> Data { + throw EngineXPCSubmissionError.connectionFailed + } + } + private actor RecordingEngineProcess: EngineProcessExecuting { private let response: Data private var invocation: ClosedEngineInvocation? diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/PayloadPrefetchTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/PayloadPrefetchTests.swift index 5ac2785..2b585bd 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/PayloadPrefetchTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/PayloadPrefetchTests.swift @@ -5,6 +5,53 @@ @testable import OmarchyAppleInstallerTrustCore final class PayloadPrefetchTests: XCTestCase { + func testFinishedNetworkMonitorDoesNotVerifyUnfinishedWork() async throws { + let controller = PayloadPrefetchController( + network: FinishedNetworkPath(), + freeSpace: MockFreeSpace(bytes: 100), + keepAwake: MockKeepAwake() + ) + await controller.start(requiredBytes: 1) { + try await Task.sleep(for: .milliseconds(30)) + throw ArtifactStageError.sizeMismatch(expected: 10, actual: 5) + } + do { + try await controller.waitUntilVerified() + XCTFail("A finished network monitor is not a verified download") + } catch { + XCTAssertEqual(error as? ArtifactStageError, .sizeMismatch(expected: 10, actual: 5)) + } + } + + func testSamePayloadInANewReleaseGetsItsOwnCanonicalFile() async throws { + let data = Data("same-payload-two-releases".utf8) + let artifact = try pinnedPayload(data) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let orchestrator = PayloadPrefetchOrchestrator( + makeNetwork: { MockNetworkPath(Self.unmetered) }, + makeKeepAwake: { MockKeepAwake() }, + makeFreeSpace: { _ in MockFreeSpace(bytes: 8_000_000_000) }, + matchesPinned: { VerifiedArtifactStager().matches($0, at: $1) }, + requiredFreeBytes: { $0 }, + stage: { artifact, directory, _ in + let file = directory.appendingPathComponent(artifact.fileName) + try data.write(to: file) + return StagedInstallerArtifact( + artifact: artifact, fileURL: file, reusedExistingFile: false) + } + ) + for release in ["first", "second"] { + let destination = root.appendingPathComponent(release).appendingPathComponent( + artifact.fileName) + orchestrator.begin( + payload: StagedInstallerArtifact( + artifact: artifact, fileURL: destination, reusedExistingFile: false)) + try await orchestrator.waitUntilVerified { _ in } + XCTAssertEqual(try Data(contentsOf: destination), data) + } + } + func testDownloadsThenVerifiesOnUnmeteredPath() async throws { let network = MockNetworkPath( InstallerNetworkPathSnapshot(isSatisfied: true, isExpensive: false, isConstrained: false) @@ -792,6 +839,15 @@ } } + private struct FinishedNetworkPath: InstallerNetworkPathObserving { + func current() -> InstallerNetworkPathSnapshot { + InstallerNetworkPathSnapshot(isSatisfied: true, isExpensive: false, isConstrained: false) + } + func updates() -> AsyncStream { + AsyncStream { $0.finish() } + } + } + private struct MockFreeSpace: InstallerFreeSpaceChecking, Sendable { let bytes: UInt64 func availableBytes() throws -> UInt64 { bytes } diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/VerifiedArtifactStagerTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/VerifiedArtifactStagerTests.swift index 0c56fe3..2584da5 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/VerifiedArtifactStagerTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/VerifiedArtifactStagerTests.swift @@ -7,6 +7,34 @@ import XCTest @testable import OmarchyAppleInstallerTrustCore final class VerifiedArtifactStagerTests: XCTestCase { + func testCancellationAtAssemblyDoesNotPromoteAndKeepsVerifiedParts() async throws { + let data = Data(repeating: 42, count: 16_384) + let fixture = try partsFixture(data: data) + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let operation = Task { + try await VerifiedArtifactStager( + downloader: RoutedFixtureDownloader(responses: fixture.responses) + ) + .stage(fixture.artifact, in: directory) { progress in + if progress.phase == .assembling { + withUnsafeCurrentTask { $0?.cancel() } + } + } + } + do { + _ = try await operation.value + XCTFail("Cancelled assembly must not promote a payload") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertFalse( + FileManager.default.fileExists( + atPath: directory.appendingPathComponent(fixture.artifact.fileName).path)) + let remaining = try FileManager.default.contentsOfDirectory(atPath: directory.path) + XCTAssertEqual(Set(remaining), Set(fixture.artifact.parts.map(\.fileName))) + } + func testDescriptorRejectsUntrustedInputs() { XCTAssertThrowsError( try descriptor(source: "http://example.com/installer.tar.gz") diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift index 366cfa9..e61eeeb 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift @@ -540,6 +540,117 @@ XCTAssertNotNil(failure.technicalDetail) } + func testQuitBlockedDuringHelperSetupAndExecutionButAllowedDuringPayloadWait() async throws { + let environment = MockInstallerEnvironment() + let setupGate = OperationGate() + let payloadGate = OperationGate() + let helperGate = OperationGate() + environment.ensureHelperGate = setupGate + environment.payloadWaitGate = payloadGate + environment.executeGate = helperGate + let session = await ready(environment) + session.presentInstallCredentials() + let credentials = try authorization() + let task = Task { await session.submit(credentials) } + await setupGate.waitUntilEntered() + XCTAssertTrue(session.isExecutionInProgress) + XCTAssertEqual(environment.payloadWaitCount, 0) + XCTAssertEqual(environment.executeCount, 0) + await setupGate.release() + await waitUntil { environment.payloadWaitCount > 0 || environment.executeCount > 0 } + XCTAssertEqual(environment.payloadWaitCount, 1) + XCTAssertEqual(environment.executeCount, 0) + XCTAssertFalse(session.isExecutionInProgress) + await payloadGate.release() + await helperGate.waitUntilEntered() + XCTAssertTrue(session.isExecutionInProgress) + await helperGate.release() + await task.value + XCTAssertFalse(session.isExecutionInProgress) + } + + func testQuitDuringPayloadWaitPreventsLateSuccessfulWaitFromSubmitting() async throws { + let environment = MockInstallerEnvironment() + let gate = OperationGate() + environment.payloadWaitGate = gate + let session = await ready(environment) + session.presentInstallCredentials() + let credentials = try authorization() + let task = Task { await session.submit(credentials) } + await gate.waitUntilEntered() + session.cancelPrefetchOnQuit() + await gate.release() + await task.value + XCTAssertEqual(environment.executeCount, 0) + XCTAssertFalse(session.hasExecutionStarted) + XCTAssertFalse(session.isExecutionInProgress) + } + + func testPayloadWaitFailureNeverSubmitsAndAllowsFreshReview() async throws { + let environment = MockInstallerEnvironment() + environment.payloadWaitError = PayloadPrefetchError.failed("verification failed") + let session = await ready(environment) + session.presentInstallCredentials() + await session.submit(try authorization()) + XCTAssertEqual(environment.executeCount, 0) + XCTAssertFalse(session.hasExecutionStarted) + XCTAssertFalse(session.isExecutionInProgress) + XCTAssertFalse(environment.hasApprovedPlan) + XCTAssertTrue(session.canInspect) + guard case .failed(let failure) = session.phase else { return XCTFail("Expected failure") } + XCTAssertTrue(failure.plainDetail.contains("no disk changes")) + } + + func testPreSubmissionFailureAllowsFreshReviewAndRevokesApproval() async throws { + let environment = MockInstallerEnvironment() + environment.executeResults = [ + .failure(InstallerPreSubmissionFailure(EngineXPCSubmissionError.connectionFailed)) + ] + let session = await ready(environment) + session.presentInstallCredentials() + await session.submit(try authorization()) + XCTAssertFalse(session.hasExecutionStarted) + XCTAssertFalse(session.isExecutionInProgress) + XCTAssertFalse(environment.hasApprovedPlan) + XCTAssertTrue(session.canInspect) + guard case .failed(let failure) = session.phase else { return XCTFail("Expected failure") } + XCTAssertTrue(failure.plainDetail.contains("not started")) + await session.inspect() + guard case .welcome = session.phase else { return XCTFail("Expected a fresh check") } + } + + func testRecoveryRetryPreSubmissionFailurePreservesCheckpointAndQuitGuard() async throws { + let environment = MockInstallerEnvironment() + environment.executeResults = [ + .failure(EngineXPCSubmissionError.recoveryAuthorizationFailed), + .failure(InstallerPreSubmissionFailure(EngineXPCSubmissionError.connectionFailed)), + ] + let session = await ready(environment) + session.presentInstallCredentials() + await session.submit(try authorization()) + XCTAssertFalse(session.isExecutionInProgress) + let setupGate = OperationGate() + let gate = OperationGate() + environment.ensureHelperGate = setupGate + environment.executeGate = gate + session.presentRecoveryRetryCredentials() + let credentials = try authorization() + let retry = Task { await session.submit(credentials) } + await setupGate.waitUntilEntered() + XCTAssertTrue(session.isExecutionInProgress) + XCTAssertEqual(environment.executeCount, 1) + await setupGate.release() + await gate.waitUntilEntered() + XCTAssertTrue(session.isExecutionInProgress) + await gate.release() + await retry.value + XCTAssertFalse(session.isExecutionInProgress) + XCTAssertTrue(session.hasExecutionStarted) + XCTAssertFalse(session.canInspect) + XCTAssertTrue(session.canRetryRecoveryAuthorization) + XCTAssertTrue(environment.hasApprovedPlan) + } + func testRecoveryRetryIsOnlyReachableWhenEligible() async throws { let environment = MockInstallerEnvironment() environment.executeResults = [ @@ -652,6 +763,7 @@ XCTAssertEqual(session.credentialSheet.context?.error, .credentialsRejected) XCTAssertEqual(session.credentialSheet.context?.mentionsBackgroundItem, true) XCTAssertEqual(environment.executeCount, 0) + XCTAssertFalse(session.isExecutionInProgress) XCTAssertTrue(session.canStartInstallation) } @@ -675,6 +787,7 @@ XCTAssertEqual(session.credentialSheet.context?.error, sheetError, "\(setupError)") XCTAssertEqual(environment.executeCount, 0, "\(setupError)") + XCTAssertFalse(session.isExecutionInProgress, "\(setupError)") // Nothing ran, so the person can simply try again. XCTAssertTrue(session.canStartInstallation, "\(setupError)") await session.submit(try authorization()) @@ -952,6 +1065,7 @@ let authorization = try authorization() let operation = Task { await session.submit(authorization) } await gate.waitUntilEntered() + XCTAssertTrue(session.isExecutionInProgress) let phase = session.phase let discards = environment.discardCount await session.inspect() @@ -966,6 +1080,7 @@ await gate.release() await operation.value let terminal = session.phase + XCTAssertFalse(session.isExecutionInProgress) environment.savedJournal?(try JournalFixture.data()) await Task.yield() XCTAssertEqual(session.phase, terminal) @@ -1349,6 +1464,9 @@ var inspectGate: OperationGate? var prepareGate: OperationGate? var executeGate: OperationGate? + var payloadWaitGate: OperationGate? + var payloadWaitError: (any Error)? + private(set) var payloadWaitCount = 0 var savedProgress: (@Sendable (AssetProgressUpdate) -> Void)? var savedJournal: (@Sendable (Data) -> Void)? var inspectError: (any Error)? @@ -1365,6 +1483,7 @@ /// Each helper setup, as the order of calls relative to execute. private(set) var calls = [String]() var ensureHelperError: (any Error)? + var ensureHelperGate: OperationGate? private(set) var prepareCount = 0 private(set) var lastOperation: InstallOperationKind? private(set) var lastEncryptLinuxDisk: Bool? @@ -1454,6 +1573,7 @@ ) async throws { calls.append( "ensureHelper:\(authorization.username)" + (reenablingSwitchedOff ? ":reenable" : "")) + await ensureHelperGate?.wait() if let ensureHelperError { self.ensureHelperError = nil throw ensureHelperError @@ -1482,7 +1602,9 @@ } func waitUntilPayloadVerified() async throws { - await prefetchGate?.wait() + payloadWaitCount += 1 + await payloadWaitGate?.wait() + if let payloadWaitError { throw payloadWaitError } } func cancelPayloadPrefetch() { diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift index c9eb512..9abfc15 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift @@ -225,12 +225,18 @@ await session.submit(dummy) } switch scenario { - case .connectionLost, .emptyReply, .helperFailure, .manualRecovery: + case .connectionLost, .emptyReply, .helperFailure, .manualRecovery, .preparedResumeMismatch: guard case .failed(let failure) = session.phase else { return XCTFail(scenario.title) } XCTAssertFalse(failure.plainDetail.contains("Nothing was changed")) XCTAssertFalse(session.canInspect) XCTAssertFalse(session.canRetryRecoveryAuthorization) if scenario != .manualRecovery { XCTAssertEqual(session.journal.checkpoints.count, 1) } + if scenario == .preparedResumeMismatch { + XCTAssertTrue(failure.headline.contains("saved checkpoint")) + XCTAssertTrue(failure.plainDetail.contains("already prepared disk space")) + XCTAssertTrue(try XCTUnwrap(failure.remedy).contains("do not delete the journal")) + XCTAssertFalse(failure.replanAvailable) + } case .completed: guard case .done = session.phase else { return XCTFail(scenario.title) } default: diff --git a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift index 953bdc2..35b1fe2 100644 --- a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift @@ -6,6 +6,39 @@ @testable import OmarchyInstallerUXCore final class PlainLanguageTests: XCTestCase { + func testPreSubmissionHelperFailureKeepsSpecificRecoveryAdvice() throws { + let failure = PlainLanguage.failure( + for: InstallerPreSubmissionFailure(EngineXPCSubmissionError.helperUnresponsive)) + XCTAssertEqual(failure.headline, "The installation service isn’t responding") + XCTAssertTrue(try XCTUnwrap(failure.remedy).contains("again, then reopen this app")) + XCTAssertFalse(try XCTUnwrap(failure.remedy).contains("Check again")) + XCTAssertTrue(failure.plainDetail.contains("not started")) + for error in [EngineXPCSubmissionError.helperUnresponsive, .connectionFailed] { + let retry = PlainLanguage.failure( + for: InstallerPreSubmissionFailure(error), retryRecoveryAvailable: true) + XCTAssertEqual(retry.headline, failure.headline) + XCTAssertEqual(retry.remedy, failure.remedy) + XCTAssertTrue(retry.retryRecoveryAvailable) + XCTAssertTrue(retry.plainDetail.contains("checkpoint is preserved")) + XCTAssertFalse(retry.plainDetail.contains("not started")) + } + } + + func testPreparedResumeMismatchExplainsCompletedWorkWithoutUnsafeRetry() throws { + let notice = EngineFailureNotice( + reason: EngineFailureReason(rawValue: 5) ?? .unclassified, + exitStatus: 1, diskUnchanged: false, + summary: "prepared resume target does not match checkpoint") + let failure = PlainLanguage.failure(for: EngineXPCSubmissionError.engineFailed(notice)) + XCTAssertEqual( + failure.headline, "The prepared installation no longer matches its saved checkpoint") + XCTAssertTrue(failure.plainDetail.contains("already prepared disk space")) + XCTAssertTrue(failure.plainDetail.contains("No further installation step")) + XCTAssertTrue(try XCTUnwrap(failure.remedy).contains("journal")) + XCTAssertFalse(failure.replanAvailable) + XCTAssertFalse(failure.retryRecoveryAvailable) + } + func testSpaceCautionsFollowAllocationAndReserveBoundary() { let plan = PlanDisplay( diskTotalBytes: 245_000_000_000, omarchyBytes: 40_000_000_000, diff --git a/docs/battle-testing.md b/docs/battle-testing.md new file mode 100644 index 0000000..326f4e4 --- /dev/null +++ b/docs/battle-testing.md @@ -0,0 +1,48 @@ +# Installer battle testing + +This source review starts from `7a61e0a604ae812ddf9c1ff0f322ad301f6da7fb`. It exercises failures with disposable files, synthetic disks, mocked helpers and the debug simulation state machine. It is not physical installation qualification, and no finite test suite covers every possible machine, disk or interruption. + +## Changes and regression coverage + +| Failure | Prevention | Regression coverage | +| --- | --- | --- | +| A resumed run's engine reports that its prepared APFS target no longer matches the saved checkpoint | Name the diagnostic, preserve the journal, explain the earlier disk preparation, and offer neither a blind retry nor a fresh install | Diagnostic classification and failure-card tests; simulation retains the preparation checkpoint without enabling a fresh install | +| A network observer ends before the download | Distinguish observer completion from work completion | Finished stream plus delayed failing work cannot publish verified | +| Two releases reuse a payload digest but stage it at different paths | Bind in-flight reuse to the artifact and canonical destination | Both release directories receive their own verified file; unchanged replans still reuse the existing download | +| Cancellation arrives during local assembly or hashing | Check cancellation between chunks and before promotion; prevent superseded generations from promoting | Deterministic cancellation at assembly leaves no final payload and retains verified parts for retry | +| Local preparation or helper ping fails before submission and locks the UI permanently | Explicit app-side pre-submission error; revoke approval and allow fresh inspection | Proven pre-submission failure unlocks initial installation; Recovery retry preserves its checkpoint; ordinary connection loss remains locked | +| Quit/Close interrupts the app while its helper continues | Guard app termination only from entry to the helper execution stage; keep payload waiting cancellable | Session guard stays clear during payload wait, turns on before execution, and clears at terminal outcomes; quit invalidates a pending submission even if its payload wait later succeeds; Recovery retry retains its checkpoint | + +The UI also displays the planned release and target, provides **Copy setup steps**, and exposes progress through native accessibility semantics while respecting Reduce Motion. These are source changes; passing Swift tests does not replace a visual or VoiceOver review. + +The engine-side fixes first developed here (resume target reconciliation, the resize geometry check, repair member and size validation, atomic engine build promotion and the real canary repair manifest) change inputs pinned in `Engine/source-lock.json`. They moved to the `codex/engine-hardening` branch, documented in its `docs/engine-hardening.md`, so they ship with a reviewed lock refresh and rebuilt engine artifact. This branch leaves `Engine/` identical to `main`; the app recognizes the prepared-resume diagnostic ahead of the engine that emits it. + +## Review regression boundaries + +The session now asks the environment to confirm payload verification before every execution; the live environment immediately returns for a verified payload. Ordinary initial installation already requires a verified payload in `canStartInstallation`, so the delayed/failing-wait regression is defensive coverage, not proof of a currently reachable multi-gigabyte wait from that screen. During the wait, Quit and window close remain available. A failed wait is pre-submission; a late successful wait after quit cannot submit. The helper execution stage retains the close/quit guard through terminal handling. + +Helper ping failures keep their specific package-reinstallation advice, including during Recovery retry, while preserving the pre-submission classification. The coordinator regression injects a failed ping before any execution request; a separate process regression verifies execution failures stay unwrapped. Failure-card tests cover the same wrapped helper errors and checkpoint-preserving Recovery advice. + +The prepared-resume mismatch is recoverable through explicit reconciliation of the saved journal and expected disk state. It does not offer a blind retry, a fresh install, or discard the journal: the earlier preparation may already have changed the disk. The failure explains that prior preparation remains, reports that this resume started no further step, and directs the owner to retained checkpoints to determine whether file installation also completed. The `preparedResumeMismatch` debug scenario exercises that card and retained activity without touching a disk. + +## Verification boundaries + +Run `bash test/all` with Bash 5+, Python 3.12+ and the documented tools. Run strict Swift formatting and both Swift configurations through XcodeBuildMCP as described in `validation.md`. The debug suite iterates every defined installation simulation scenario; these remain synthetic environments. + +The source lock, engine sources, trust roots, release descriptor and blocked-model rules are unchanged from `main`, so `Engine/source-lock.json` still describes the authenticated `.28` archive. In particular `apple,j614s` remains blocked. + +## Remaining practical work + +1. **Read-only subprocess hangs (high priority).** `MacHostInspection.swift` drains stdout before stderr although it discards stderr; enough stderr can fill the pipe and deadlock the child. Discard that unused stream or drain it concurrently. Add bounded timeouts and output caps to read-only disk/engine inspection with fixture children that flood stderr or never exit. Do not apply a generic kill timeout to an active disk write or APFS mutation. +2. **Restart-safe download recovery (medium priority).** Prefetch work directories are tracked only in memory and named with random UUIDs. After a crash, a new process cannot reclaim or reuse their multi-gigabyte parts. Design a narrowly scoped owner lease/manifest; reverify parts before reuse and reclaim only demonstrably abandoned directories. Test low-space relaunch and a concurrently live unrelated owner. Do not recursively clear the shared staging folder. +3. **Immediate unsupported-host feedback (medium priority).** `LiveInstallerEnvironment.inspect` waits for a catalog to enrich a model it already knows is unsupported; network timeout can delay the refusal. Show the refusal immediately and enrich the optional model list asynchronously or from an already validated catalog. Preserve the blocked-device gate. +4. **Prefetch simulation coverage (medium priority).** The simulation environment still uses preparation-stage download scenarios and does not model the live plan-screen prefetch lifecycle. Add waiting-for-network, pause/resume, verification, failure/retry and release-change cases through the same session interface. + +## Native UI and physical scenario checklist + +- During a pending payload wait, Command-Q, Command-W and the close button must remain available and cancel prefetch. During helper credential verification, active installation and Recovery retry execution, try Command-Q, Command-W and the close button. They must preserve the running session. Repeat after rejected credentials, completion and failure; normal exit and requested shutdown must be allowed. +- Review the release/target details, minimum-size and narrow-window layouts, keyboard size editing, light/dark mode, VoiceOver progress and toggling Reduce Motion while progress is visible. +- Copy Recovery steps before shutdown; retain warnings and step details. Simulate rejected shutdown and confirm instructions stay visible. +- Exercise all installation and removal simulation outcomes, including unknown helper results, changed allocation, rejected credentials, offline channels and partial removal. A missing checkpoint must never be presented as proof of no writes. +- On separately authorized, backed-up supported hardware: cold/warm downloads, dropped Wi-Fi, disk pressure, snapshots/FileVault, APFS resize refusal, interruption at each mutation boundary, Recovery retry, first boot, encryption choice and removal. Preserve recovery paths and record the exact catalog, payload, engine and app identities. +- Measure download, app/helper import, resize, decompression/write, synchronization, readback and Recovery separately. Keep streaming hashes, authenticated admission and sequential disk mutation. Do not promise a faster physical install without measurements. diff --git a/docs/pr29-review-validation.md b/docs/pr29-review-validation.md new file mode 100644 index 0000000..20bb5ec --- /dev/null +++ b/docs/pr29-review-validation.md @@ -0,0 +1,65 @@ +# PR 29 review response validation + +Validated source candidate: `ebe76e695f4a1c68f30b25d152f8394027b792a0`, on `codex/battle-test-installer`, based on PR head `51ad0d83513d39a499c9c167c856189248584f50`. This record is a subsequent documentation-only commit. The source candidate and all results below were produced locally on 2026-09-29/30 (Asia/Kolkata). No commits were pushed and no review threads were resolved. + +After `main` began checking every locked engine input against `Engine/source-lock.json`, the engine overlay changes and their Python tests were moved from this PR to the `codex/engine-hardening` branch, to ship with a rebuilt engine. The Python counts below include those tests; the Swift results are unaffected. + +The specification was Marcelo's review submitted 2026-09-29 at 01:47:55 UTC and Scott's review submitted at 03:37:55 UTC, including Scott's inline comments, on [PR 29](https://github.com/omacom/omarchy-mac-installer/pull/29). GitHub still reported the same reviews and head after local validation. + +## Implemented changes + +- Payload preparation and helper execution have separate quit/close state. A failed payload wait remains a pre-submission failure. Cancellation prevents a late successful wait from submitting a request. +- Wrapped helper connection/ping failures preserve the package-reinstallation remedy, including Recovery retry's existing checkpoint. Execution failures remain unwrapped. +- Prepared-resume target mismatches have a named diagnostic, preserve the journal, explain prior disk preparation, and offer no blind retry or fresh installation over uncertain disk state. +- An exact real-canary repair manifest exercises candidate discovery. Its provenance, size/alignment contract, and evidence boundary moved with the engine changes to `docs/engine-hardening.md` on `codex/engine-hardening`. +- The source-lock refresh and authenticated engine rebuild remain an explicit, separately reviewed release follow-up. The old archive does not contain these engine changes. + +## Automated validation + +Host: arm64 macOS 27.0 (`26A428`), Xcode 27.0 (`27A266a`), Swift 6.4, Python 3.14.7, Bash 5.3.15. XcodeBuildMCP 2.7.0 was downloaded from its official release and its archive matched the repository's pinned SHA-256 `bd724a2c0e6ffe027b3f46257e66d626149a64cd045f4867124bd683b3cf081a`. + +| Check | Result | +| --- | --- | +| Focused Swift regression closure | 114 passed, zero failures or skips | +| Focused Python checks | 21 Asahi adapter, 12 repair, and 13 stage-one tests passed | +| `bash test/all` with Bash 5 | 174 Python tests passed: 38 engine tooling, 112 overlay, 24 scripts; shell and packaging fixtures passed | +| Strict Swift formatting | Passed for Package.swift, Sources, Plugins, and Tests | +| Full debug tests through XcodeBuildMCP | 577 XCTest passes and 4 Swift Testing passes; one XCTest skipped; zero failures | +| Full release tests through XcodeBuildMCP | 567 XCTest passes and 4 Swift Testing passes; one XCTest skipped; zero failures | +| Python compilation, shell syntax, Git whitespace | Passed | +| Working tree at source qualification | Clean | + +Both configurations skipped `PrivateM3CatalogTests.testActualDraftThroughSealedCatalogTrustPath` because `OMARCHY_PRIVATE_CATALOG` was not supplied. Read-only live host inspection passed. Release compilation emitted three inherited weak-capture warnings at `PayloadPrefetch.swift:177,676` and `InstallerSession.swift:739`; the warning-bearing code was not changed by this response. + +Focused regressions failed before their corresponding fixes for helper wording, prepared-target diagnostics, and the session verification/quit boundary. The real manifest test is a compatibility characterization, not a reproduced defect. Ordinary initial installation already requires a verified payload, so delayed payload-wait coverage is defensive; it does not prove that Scott's multi-gigabyte wait is reachable from the current normal install screen. + +The standalone repository does not contain the parent Omarchy repository's `bin/omarchy` command router; those parent-repository checks are inapplicable. No image-builder source changed, and its separate full suite was not repeated. + +## Native simulation + +The exact debug executable from the qualified candidate ran with explicit `--simulate --simulate-scenario=preparedResumeMismatch --simulate-continue` flags in a disposable local review bundle. The simulation used its built-in dummy account and synthetic disk; no real credentials were entered. + +The initial review bundle had incorrect executable metadata, causing UI attachment timeouts. Correcting that disposable bundle's metadata allowed native accessibility inspection and screenshots without changing repository source. + +- During slow simulated helper execution, Command-Q and Command-W left the running session intact and the close control was unavailable. +- The terminal mismatch card displayed the explanation of prior preparation and journal-preservation guidance with no clipping or overlap in the inspected window. +- Expanding Last verified steps displayed Started preparing disk space and Disk space reserved for Omarchy, along with the copy-installation-record control. No fresh-install or Recovery retry button was offered. +- The close control returned after failure. Command-W terminated the simulation. The UI tool's subsequent state read automatically relaunched the disposable executable without its simulation arguments; it immediately rejected installation because the validation engine was absent. That exact process was stopped and the bundle's simulation-only launcher restored. No helper registration or disk operation occurred. + +The pending payload wait is covered through the session's controllable test environment; the native simulation does not model that prefetch lifecycle. Native VoiceOver, all window sizes, and physical installation were not qualified by this review. + +## Standards + +Independent read-only review of `git diff origin/main...ebe76e6`, with `origin/main` at `9ce0c6f3eb7aabb6966fad1a97db336fc3a7e817`, found no blocking bugs, documented-standard violations, or actionable code-smell findings. Review covered execution and cancellation guards, pre-submission error boundaries, resumed-target reconciliation, repair size validation, and artifact promotion. Source locks, release trust configuration, and blocked-host restrictions remain unchanged. + +## Spec + +Independent read-only review found no blocking source defects or unrelated scope in the review-response commit. The failure diagnostics, quit/close boundary, helper-specific remedies, narrow wrapping regressions, and documented engine-lock follow-up match the requested behavior. + +Marcelo requested checking the producer or a real shipped manifest. The checksum-verified fixture is a real manifest consumed by a physical non-release canary. It establishes compatibility with that artifact; it does not establish compatibility with current production output or verify an unavailable producer implementation. That limitation remains explicit rather than inferred away. + +Review totals: Standards: zero findings. Spec: zero blocking source findings and one compatibility-evidence limitation. QA verdict: PASS for local source qualification. + +## Release boundary + +No physical installation, privileged helper registration, production signing, authenticated engine rebuild, publication, or deployment was performed. Before shipping the engine fixes, refresh and review the lock together with the rebuilt archive and artifact/catalog identity, validate the assembled package, and qualify interrupted stage-one and Recovery resume on authorized supported hardware. `apple,j614s` remains blocked.