Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
</CommandLineArgument>
<CommandLineArgument
argument = "-VBForceVirtualInstallationBackend YES"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-VBSimulateInstallFailure YES"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
argument = "-VBSimulateInstall YES"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-VBSimulateInstallFailure YES"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-VBSimulateDownload YES"
isEnabled = "NO">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
argument = "-VBSimulateNonAPFSVolume YES"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-VBSimulateInstallFailure YES"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
Expand Down
12 changes: 12 additions & 0 deletions VirtualCore/Source/Restore/Installation/RestoreBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ public protocol RestoreBackend: AnyObject {
public protocol VirtualMachineProvidingRestoreBackend: RestoreBackend {
var virtualMachine: AnyPublisher<VZVirtualMachine?, Never> { get }
}

public struct RestoreFailure: LocalizedError, Sendable {
public let message: String
public let diagnosticFileURLs: [URL]

public var errorDescription: String? { message }

public init(message: String, diagnosticFileURLs: [URL]) {
self.message = message
self.diagnosticFileURLs = diagnosticFileURLs
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import VirtualInstallation
public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRestoreBackend {
private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VirtualInstallationRestoreBackend.self))

#if DEBUG
/// Enables an end-to-end VirtualInstallation failure using `-VBSimulateInstallFailure YES`.
public static var isFailureSimulationEnabled: Bool {
UserDefaults.standard.bool(forKey: "VBSimulateInstallFailure")
}

private static let failureSimulationECID: ECID = 0xF411_0000_0000_0001
#endif

public var consolePredicate: LogStreamer.Predicate { .custom(kVirtualInstallationUnifiedLogPredicate) }

public let model: VBVirtualMachine
Expand All @@ -28,6 +37,14 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes
public var virtualMachine: AnyPublisher<VZVirtualMachine?, Never> { virtualMachineSubject.eraseToAnyPublisher() }

public func install() async throws {
#if DEBUG
if Self.isFailureSimulationEnabled {
logger.notice("Running debug-only end-to-end install failure simulation")
try await runInstaller(ecid: Self.failureSimulationECID, simulateFailure: true)
return
}
#endif

logger.debug("Install - creating configuration")

let installModel = model.forInstallation()
Expand All @@ -52,7 +69,15 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes

logger.debug("Activating installer")

let installer = VIVirtualMachineInstaller(ecid: ecid, bundleURL: restoreImageFileURL)
try await runInstaller(ecid: ecid, simulateFailure: false)
}

private func runInstaller(ecid: ECID, simulateFailure: Bool) async throws {
let installer = VIVirtualMachineInstaller(
ecid: ecid,
bundleURL: restoreImageFileURL,
simulateFailure: simulateFailure
)

_installer = installer

Expand All @@ -64,7 +89,14 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes

try Task.checkCancellation()

try await installer.install()
do {
try await installer.install()
} catch let error as DeviceRestoreFailure {
throw RestoreFailure(
message: error.localizedDescription,
diagnosticFileURLs: error.logFileURLs
)
}
}

public func cancel() async {
Expand Down
15 changes: 15 additions & 0 deletions VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ struct DeviceRestoreLoggers: @unchecked Sendable {
var device: RestoreLog? = nil
var host: RestoreLog? = nil
var serial: RestoreLog? = nil

var fileURLs: [URL] {
[global, device, host, serial].compactMap { log in
guard let fileURL = log?.fileURL,
FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
return fileURL
}
}

var mostRecentRestoreError: NSError? {
[global, host, device, serial]
.lazy
.compactMap { $0?.mostRecentRestoreError() }
.first
}
}

typealias DeviceRestoreProgressClosure = @Sendable (_ info: CFDictionary) -> Void
Expand Down
31 changes: 29 additions & 2 deletions VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class DeviceRestoreDriver: @unchecked Sendable {
private let backend: any DeviceRestoreBackend

private let personalizedBundleURL: URL
private let logDirectoryURL: URL

let artifactStorageURL: URL
let loggers: DeviceRestoreLoggers
Expand All @@ -26,10 +27,16 @@ final class DeviceRestoreDriver: @unchecked Sendable {
.appending(path: "Personalized_\(bundleURL.deletingPathExtension().lastPathComponent)_\(ecid)_\(Int(Date.now.timeIntervalSinceReferenceDate))", directoryHint: .isDirectory)
.ensureExistingDirectory(createIfNeeded: true)

let logBaseURL = try artifactStorageURL
let logRootURL = try artifactStorageURL
.appending(path: "Logs", directoryHint: .isDirectory)
.ensureExistingDirectory(createIfNeeded: true)

let logBaseURL = try logRootURL
.appending(path: "Restore_\(ecid)_\(UUID().uuidString)", directoryHint: .isDirectory)
.ensureExistingDirectory(createIfNeeded: true)

self.logDirectoryURL = logBaseURL

let loggers = DeviceRestoreLoggers(
global: RestoreLog(fileURL: logBaseURL.appending(path: "global.log")),
device: RestoreLog(fileURL: logBaseURL.appending(path: "device.log")),
Expand All @@ -52,14 +59,34 @@ final class DeviceRestoreDriver: @unchecked Sendable {

try backend.restore(deviceECID: ecid, options: options, loggers: loggers) { [weak self] info in
do {
let state = try DeviceRestoreState(info: info)
var state = try DeviceRestoreState(info: info)

if case .failure(let reportedError) = state.outcome {
let error = reportedError ?? self?.loggers.mostRecentRestoreError.map(CodableError.init)
state = state.replacingFailure(error: error, logFileURLs: self?.loggers.fileURLs ?? [])
}

progressHandler(state)

if case .success = state.outcome {
self?.discardLogs()
}
} catch {
self?.logger.error("Failed to parse progress info: \(error, privacy: .public). Info:\n\(info, privacy: .public)")
}
}
}

private func discardLogs() {
guard FileManager.default.fileExists(atPath: logDirectoryURL.path) else { return }

do {
try FileManager.default.removeItem(at: logDirectoryURL)
} catch {
logger.error("Failed to discard logs for a successful restore: \(error, privacy: .public)")
}
}

private static let preservePersonalizedBundles = ProcessInfo.processInfo.environment["VI_PRESERVE_PERSONALIZED_BUNDLES"] == "1"

private func buildRestoreOptions() -> RestoreOptionsDictionary {
Expand Down
63 changes: 63 additions & 0 deletions VirtualInstallation/Source/Backend/RestoreLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Foundation
import os

public final class RestoreLog: @unchecked Sendable {
private static let maximumErrorSearchLength: UInt64 = 4 * 1_024 * 1_024

private let logger: Logger
public let fileURL: URL

Expand Down Expand Up @@ -46,6 +48,55 @@ public final class RestoreLog: @unchecked Sendable {
}
}

func mostRecentRestoreError() -> NSError? {
do {
let contents = try tailContents(maximumLength: Self.maximumErrorSearchLength)
return Self.mostRecentRestoreError(in: contents)
} catch {
logger.error("Failed to read restore log while looking for an error: \(error, privacy: .public)")
return nil
}
}

static func mostRecentRestoreError(in contents: String) -> NSError? {
let domainPrefix = "CFError domain:"
let codePrefix = " code:"
let descriptionPrefix = " description:"

for line in contents.components(separatedBy: .newlines).reversed() {
guard let domainPrefixRange = line.range(of: domainPrefix) else { continue }

let domainStart = domainPrefixRange.upperBound
guard let codePrefixRange = line.range(
of: codePrefix,
range: domainStart..<line.endIndex
) else { continue }

let codeStart = codePrefixRange.upperBound
guard let descriptionPrefixRange = line.range(
of: descriptionPrefix,
range: codeStart..<line.endIndex
) else { continue }

let domain = line[domainStart..<codePrefixRange.lowerBound]
.trimmingCharacters(in: .whitespaces)
let codeString = line[codeStart..<descriptionPrefixRange.lowerBound]
.trimmingCharacters(in: .whitespaces)
let description = line[descriptionPrefixRange.upperBound...]
.trimmingCharacters(in: .whitespaces)

guard !domain.isEmpty, let code = Int(codeString), !description.isEmpty else { continue }

return NSError(
domain: domain,
code: code,
userInfo: [NSLocalizedDescriptionKey: description]
)
}

return nil
}

private let _fileHandle = OSAllocatedUnfairLock<FileHandle?>(initialState: nil)
private var fileHandle: FileHandle {
get throws {
Expand Down Expand Up @@ -79,5 +130,17 @@ public final class RestoreLog: @unchecked Sendable {
}
}

private func tailContents(maximumLength: UInt64) throws -> String {
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }

let endOffset = try handle.seekToEnd()
let startOffset = endOffset > maximumLength ? endOffset - maximumLength : 0
try handle.seek(toOffset: startOffset)

let data = try handle.readToEnd() ?? Data()
return String(decoding: data, as: UTF8.self)
}

deinit { invalidate() }
}
Loading