From ece7cfb1b739b217afc328b7b4089d2928d45624 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:08:10 +0900 Subject: [PATCH 1/4] Version resume compatibility and clean up simulators --- Docs/generation.md | 14 +- Package.swift | 19 +- .../PrivateHeaderKitBuildInfoPlugin.swift | 60 +++ .../PrivateHeaderKitBuildInfoTool.swift | 166 ++++++++ .../PrivateHeaderKitArguments.swift | 2 +- .../PrivateHeaderKitCommand.swift | 218 ++++++---- .../PrivateHeaderKitGenerationClient.swift | 38 +- .../PrivateHeaderKitInteractive.swift | 61 +-- .../PrivateHeaderGeneration.swift | 10 +- .../PrivateHeaderGenerationExecutor.swift | 50 ++- .../PrivateHeaderGenerationRawDumping.swift | 46 ++- .../PrivateHeaderGenerationState.swift | 5 +- .../PrivateHeaderGenerationStore.swift | 11 - .../PrivateHeaderKitHelperProtocol.swift | 54 ++- .../PrivateHeaderKitInstallMain.swift | 12 +- Sources/PrivateHeaderKitTooling/Simctl.swift | 211 ++++++---- .../PrivateHeaderKitBuildInfoToolTests.swift | 43 ++ .../PrivateHeaderKitCLITests.swift | 313 +++++++++++---- ...PrivateHeaderGenerationExecutorTests.swift | 146 ++++++- ...ivateHeaderGenerationRawDumpingTests.swift | 19 +- .../PrivateHeaderGenerationStoreTests.swift | 13 +- .../PrivateHeaderGenerationTests.swift | 106 ++++- .../PrivateHeaderKitHelperProtocolTests.swift | 27 +- .../PrivateHeaderKitInstallTests.swift | 6 + .../PrivateHeaderKitRawDumpTests.swift | 3 +- .../TestSupport.swift | 17 +- .../ToolingDeterministicTests.swift | 375 ++++++++++++++---- 27 files changed, 1600 insertions(+), 445 deletions(-) create mode 100644 Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift create mode 100644 Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift create mode 100644 Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift diff --git a/Docs/generation.md b/Docs/generation.md index b53aea7..4a56146 100644 --- a/Docs/generation.md +++ b/Docs/generation.md @@ -20,8 +20,11 @@ header directory when a run starts and again in the completion summary. macOS generation works from the host system. iOS and watchOS generation require Xcode, `xcrun`, `simctl`, and the selected Simulator runtime. PrivateHeaderKit -selects and boots a compatible simulator device for the run. It does not use a -connected iPhone or Apple Watch as a generation source. +creates and boots one dedicated simulator device for the run, then deletes that +exact device after generation, failure, or interruption. It does not use a +connected iPhone or Apple Watch as a generation source. An explicit `--device` +selects an existing borrowed simulator instead; PrivateHeaderKit never deletes +that device. ## Automation @@ -181,6 +184,13 @@ whether to continue or restart. The interactive wizard presents the same Continue or Restart choice when it finds compatible unfinished work. +Resume compatibility is bound to the PrivateHeaderKit producer version emitted +by the raw helper, the selected source and Simulator runtime, generation +options, and the loaded shared-cache cohort when used. A simulator device UDID +is only a temporary execution address and does not affect compatibility. After +upgrading from state created before producer-version tracking, select Restart or +use `--fresh` once; existing published headers remain available until replaced. + ## Legacy Output PrivateHeaderKit does not silently adopt either legacy form: diff --git a/Package.swift b/Package.swift index 6df2b4e..82ba15b 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,10 @@ let package = Package( targets: [ .target( name: "PrivateHeaderKitHelperProtocol", - dependencies: [] + dependencies: [], + plugins: [ + .plugin(name: "PrivateHeaderKitBuildInfoPlugin"), + ] ), .target( name: "PrivateHeaderKitExecutableResolution", @@ -151,6 +154,14 @@ let package = Package( "PrivateHeaderKitRawDumpCore", ] ), + .executableTarget( + name: "PrivateHeaderKitBuildInfoTool" + ), + .plugin( + name: "PrivateHeaderKitBuildInfoPlugin", + capability: .buildTool(), + dependencies: ["PrivateHeaderKitBuildInfoTool"] + ), .executableTarget( name: "PrivateHeaderKitToolingTestHelper", dependencies: [ @@ -165,6 +176,12 @@ let package = Package( ], path: "Tests/PrivateHeaderKitTestSupport" ), + .testTarget( + name: "PrivateHeaderKitBuildInfoToolTests", + dependencies: [ + "PrivateHeaderKitBuildInfoTool", + ] + ), .testTarget( name: "PrivateHeaderKitHelperProtocolTests", dependencies: [ diff --git a/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift new file mode 100644 index 0000000..087422b --- /dev/null +++ b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift @@ -0,0 +1,60 @@ +import Foundation +import PackagePlugin + +@main +struct PrivateHeaderKitBuildInfoPlugin: BuildToolPlugin { + private static let environmentKey = "PRIVATEHEADERKIT_BUILD_VERSION" + + func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { + guard target is SourceModuleTarget else { return [] } + + let outputFile = context.pluginWorkDirectoryURL.appending( + path: "PrivateHeaderKitBuildInfo.generated.swift" + ) + let tool = try context.tool(named: "PrivateHeaderKitBuildInfoTool") + var arguments = [ + "--output", outputFile.path, + "--package-directory", context.package.directoryURL.path, + ] + if let environmentVersion = ProcessInfo.processInfo.environment[Self.environmentKey] { + arguments.append(contentsOf: ["--environment-version", environmentVersion]) + } else if let gitVersion = Self.gitDescribe(in: context.package.directoryURL) { + // Keep the Git identity in the build-command signature. Reading Git only inside the + // tool would let SwiftPM reuse a stale generated source after HEAD changes. + arguments.append(contentsOf: ["--environment-version", gitVersion]) + } + + return [ + .buildCommand( + displayName: "Generate PrivateHeaderKit build info", + executable: tool.url, + arguments: arguments, + outputFiles: [outputFile] + ) + ] + } + + private static func gitDescribe(in packageDirectory: URL) -> String? { + let process = Process() + let outputPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = [ + "-C", packageDirectory.path, + "describe", "--tags", "--always", "--dirty", + ] + process.standardOutput = outputPipe + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let value = String( + decoding: outputPipe.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift new file mode 100644 index 0000000..bf5ede4 --- /dev/null +++ b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift @@ -0,0 +1,166 @@ +import Foundation + +@main +struct PrivateHeaderKitBuildInfoTool { + static func main() throws { + let options = try Options.parse(arguments: CommandLine.arguments) + let outputURL = URL(fileURLWithPath: options.outputPath) + let version = try BuildVersionResolver.resolve( + environmentVersion: options.environmentVersion, + packageDirectory: URL(fileURLWithPath: options.packageDirectoryPath) + ) + let source = BuildVersionResolver.generatedSource(version: version) + + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if let existing = try? String(contentsOf: outputURL, encoding: .utf8), + existing == source + { + return + } + try source.write(to: outputURL, atomically: true, encoding: .utf8) + } +} + +private struct Options { + let outputPath: String + let packageDirectoryPath: String + let environmentVersion: String? + + static func parse(arguments: [String]) throws -> Self { + var outputPath: String? + var packageDirectoryPath: String? + var environmentVersion: String? + var index = 1 + while index < arguments.count { + switch arguments[index] { + case "--output": + outputPath = try value(after: index, in: arguments, for: "--output") + index += 2 + case "--package-directory": + packageDirectoryPath = try value( + after: index, + in: arguments, + for: "--package-directory" + ) + index += 2 + case "--environment-version": + environmentVersion = try value( + after: index, + in: arguments, + for: "--environment-version" + ) + index += 2 + default: + throw BuildInfoToolError.message("unknown option: \(arguments[index])") + } + } + guard let outputPath else { + throw BuildInfoToolError.message("--output is required") + } + guard let packageDirectoryPath else { + throw BuildInfoToolError.message("--package-directory is required") + } + return Self( + outputPath: outputPath, + packageDirectoryPath: packageDirectoryPath, + environmentVersion: environmentVersion + ) + } + + private static func value( + after index: Int, + in arguments: [String], + for option: String + ) throws -> String { + guard index + 1 < arguments.count else { + throw BuildInfoToolError.message("\(option) requires a value") + } + return arguments[index + 1] + } +} + +package enum BuildVersionResolver { + package static func resolve( + environmentVersion: String?, + packageDirectory: URL, + gitDescribe: (URL) throws -> String? = defaultGitDescribe(in:) + ) throws -> String { + if let version = try normalized(environmentVersion) { + return version + } + if let version = try normalized(gitDescribe(packageDirectory)) { + return version + } + throw BuildInfoToolError.message( + "unable to determine PrivateHeaderKit build version; " + + "set PRIVATEHEADERKIT_BUILD_VERSION or build from a Git checkout" + ) + } + + package static func generatedSource(version: String) -> String { + """ + // Generated by PrivateHeaderKitBuildInfoTool. Do not edit. + + package enum PrivateHeaderKitBuildInfo { + package static let version = "\(escapedStringLiteral(version))" + } + """ + } + + private static func normalized(_ value: String?) throws -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + guard normalized.utf8.count <= 256, + normalized.unicodeScalars.allSatisfy({ scalar in + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + false + default: + true + } + }) + else { + throw BuildInfoToolError.message( + "build version must be a printable string of at most 256 bytes" + ) + } + return normalized + } + + private static func defaultGitDescribe(in packageDirectory: URL) throws -> String? { + let process = Process() + let outputPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = [ + "-C", packageDirectory.path, + "describe", "--tags", "--always", "--dirty", + ] + process.standardOutput = outputPipe + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } + + private static func escapedStringLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} + +package enum BuildInfoToolError: Error, CustomStringConvertible { + case message(String) + + package var description: String { + switch self { + case .message(let message): message + } + } +} diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitArguments.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitArguments.swift index 66dd934..7ea5fbe 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitArguments.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitArguments.swift @@ -74,7 +74,7 @@ struct PrivateHeaderKitGenerationArguments: ParsableArguments { if let systemRoot, systemRoot.isEmpty { throw ValidationError("Argument '--system-root ' must not be empty") } - if let device, device.isEmpty { + if let device, device.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { throw ValidationError("Argument '--device ' must not be empty") } if let simulatorHelperPath, simulatorHelperPath.isEmpty { diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift index c9c14c6..eb97411 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import PrivateHeaderKitCore +import PrivateHeaderKitHelperProtocol import PrivateHeaderKitTooling #if canImport(Darwin) @@ -91,6 +92,7 @@ struct PrivateHeaderKitSimulatorResolution: Equatable, Sendable { let metadataIsSeed: Bool let deviceName: String let deviceUDID: String + let deviceOwnership: SimulatorDeviceOwnership init( runtimeVersion: String, @@ -99,7 +101,8 @@ struct PrivateHeaderKitSimulatorResolution: Equatable, Sendable { resolvedRuntimeRoot: String, metadataIsSeed: Bool, deviceName: String, - deviceUDID: String + deviceUDID: String, + deviceOwnership: SimulatorDeviceOwnership = .borrowed ) { self.runtimeVersion = runtimeVersion self.runtimeBuild = runtimeBuild @@ -108,17 +111,19 @@ struct PrivateHeaderKitSimulatorResolution: Equatable, Sendable { self.metadataIsSeed = metadataIsSeed self.deviceName = deviceName self.deviceUDID = deviceUDID + self.deviceOwnership = deviceOwnership } - init(runtime: RuntimeInfo, metadataIsSeed: Bool, device: DeviceInfo) { + init(runtime: RuntimeInfo, metadataIsSeed: Bool, resolvedDevice: ResolvedSimulatorDevice) { self.init( runtimeVersion: runtime.version, runtimeBuild: runtime.build, runtimeIdentifier: runtime.identifier, resolvedRuntimeRoot: runtime.runtimeRoot, metadataIsSeed: metadataIsSeed, - deviceName: device.name, - deviceUDID: device.udid + deviceName: resolvedDevice.device.name, + deviceUDID: resolvedDevice.device.udid, + deviceOwnership: resolvedDevice.ownership ) } } @@ -126,6 +131,9 @@ struct PrivateHeaderKitSimulatorResolution: Equatable, Sendable { typealias PrivateHeaderKitSimulatorResolver = @Sendable ( PrivateHeaderKitGenerateCommand ) async throws -> PrivateHeaderKitSimulatorResolution +typealias PrivateHeaderKitSimulatorCleaner = @Sendable ( + PrivateHeaderKitSimulatorResolution +) async throws -> Void typealias PrivateHeaderKitHelperResolver = @Sendable ( URL, String?, @@ -146,25 +154,18 @@ func resolvePrivateHeaderKitReleaseMetadata( struct PrivateHeaderKitHelperPlan: Sendable { let helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs - let toolCompatibilityIdentity: String fileprivate let preparation: PrivateHeaderKitHelperPreparation - init( - helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs, - toolCompatibilityIdentity: String - ) { + init(helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs) { self.helperURLs = helperURLs - self.toolCompatibilityIdentity = toolCompatibilityIdentity self.preparation = .ready } fileprivate init( helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs, - toolCompatibilityIdentity: String, preparation: PrivateHeaderKitHelperPreparation ) { self.helperURLs = helperURLs - self.toolCompatibilityIdentity = toolCompatibilityIdentity self.preparation = preparation } } @@ -198,6 +199,7 @@ func runPrivateHeaderKitCommand( currentExecutableURL: URL? = Bundle.main.executableURL, generationClient: PrivateHeaderKitGenerationClient = .live, simulatorResolver: @escaping PrivateHeaderKitSimulatorResolver = resolvePrivateHeaderKitSimulator, + simulatorCleaner: @escaping PrivateHeaderKitSimulatorCleaner = cleanupPrivateHeaderKitSimulator, helperResolver: @escaping PrivateHeaderKitHelperResolver = resolvePrivateHeaderKitHelperURLs, releaseMetadataResolver: @escaping PrivateHeaderKitReleaseMetadataResolver = resolvePrivateHeaderKitReleaseMetadata, @@ -247,6 +249,7 @@ func runPrivateHeaderKitCommand( currentExecutableURL: currentExecutableURL, generationClient: generationClient, simulatorResolver: simulatorResolver, + simulatorCleaner: simulatorCleaner, helperResolver: helperResolver, releaseMetadataResolver: releaseMetadataResolver, sourceProvider: interactiveSourceProvider, @@ -264,6 +267,7 @@ func runPrivateHeaderKitCommand( currentExecutableURL: currentExecutableURL, generationClient: generationClient, simulatorResolver: simulatorResolver, + simulatorCleaner: simulatorCleaner, helperResolver: helperResolver, releaseMetadataResolver: releaseMetadataResolver, outputLogger: outputLogger, @@ -291,6 +295,7 @@ func runPrivateHeaderKitGenerateCommand( currentExecutableURL: URL?, generationClient: PrivateHeaderKitGenerationClient, simulatorResolver: PrivateHeaderKitSimulatorResolver, + simulatorCleaner: @escaping PrivateHeaderKitSimulatorCleaner = cleanupPrivateHeaderKitSimulator, helperResolver: PrivateHeaderKitHelperResolver = resolvePrivateHeaderKitHelperURLs, releaseMetadataResolver: PrivateHeaderKitReleaseMetadataResolver = resolvePrivateHeaderKitReleaseMetadata, @@ -298,45 +303,51 @@ func runPrivateHeaderKitGenerateCommand( outputLogger: @escaping PrivateHeaderKitOutputLogger, errorLogger: @escaping PrivateHeaderKitOutputLogger ) async throws -> Int32 { - do { - let request = try await preparePrivateHeaderKitGenerationRequest( - command, - invokedProgramName: invokedProgramName, - currentExecutableURL: currentExecutableURL, - simulatorResolver: simulatorResolver, - helperResolver: helperResolver, - releaseMetadataResolver: releaseMetadataResolver, - outputLogger: outputLogger - ) + try await withPrivateHeaderKitSimulatorSession( + command, + resolver: simulatorResolver, + cleaner: simulatorCleaner, + outputLogger: outputLogger + ) { simulatorResolution in do { - let preparedGeneration = try await generationClient.prepare(request) - return try await runPrivateHeaderKitPreparedGeneration( - preparedGeneration, - request: request, - targetQuery: command.targetQuery, - resumeBehavior: command.resumeBehavior, - resultScreenClearer: resultScreenClearer, - outputLogger: outputLogger, - errorLogger: errorLogger + let request = try await preparePrivateHeaderKitGenerationRequest( + command, + invokedProgramName: invokedProgramName, + currentExecutableURL: currentExecutableURL, + simulatorResolution: simulatorResolution, + helperResolver: helperResolver, + releaseMetadataResolver: releaseMetadataResolver ) - } catch let error as PrivateHeaderGeneration.GenerationError { - if Task.isCancelled { - throw CancellationError() + do { + let preparedGeneration = try await generationClient.prepare(request) + return try await runPrivateHeaderKitPreparedGeneration( + preparedGeneration, + request: request, + targetQuery: command.targetQuery, + resumeBehavior: command.resumeBehavior, + resultScreenClearer: resultScreenClearer, + outputLogger: outputLogger, + errorLogger: errorLogger + ) + } catch let error as PrivateHeaderGeneration.GenerationError { + if Task.isCancelled { + throw CancellationError() + } + renderPrivateHeaderKitGenerationError( + error, + sourceDisplayName: request.source.label.displayName, + targetQuery: command.targetQuery, + screenClearer: resultScreenClearer, + outputLogger: errorLogger + ) + return 2 } - renderPrivateHeaderKitGenerationError( - error, - sourceDisplayName: request.source.label.displayName, - targetQuery: command.targetQuery, - screenClearer: resultScreenClearer, - outputLogger: errorLogger - ) + } catch is CancellationError { + throw CancellationError() + } catch { + errorLogger("error: \(error)") return 2 } - } catch is CancellationError { - throw CancellationError() - } catch { - errorLogger("error: \(error)") - return 2 } } @@ -344,22 +355,11 @@ func preparePrivateHeaderKitGenerationRequest( _ command: PrivateHeaderKitGenerateCommand, invokedProgramName: String, currentExecutableURL: URL?, - simulatorResolver: PrivateHeaderKitSimulatorResolver, + simulatorResolution: PrivateHeaderKitSimulatorResolution?, helperResolver: PrivateHeaderKitHelperResolver, releaseMetadataResolver: PrivateHeaderKitReleaseMetadataResolver = - resolvePrivateHeaderKitReleaseMetadata, - outputLogger: @escaping PrivateHeaderKitOutputLogger + resolvePrivateHeaderKitReleaseMetadata ) async throws -> PrivateHeaderKitGenerationRequest { - let simulatorResolution: PrivateHeaderKitSimulatorResolution? - if command.platform.simulatorPlatform != nil { - let resolution = try await simulatorResolver(command) - outputLogger( - "selected simulator: \(resolution.deviceName) (\(resolution.deviceUDID))" - ) - simulatorResolution = resolution - } else { - simulatorResolution = nil - } let effectiveSource = try effectiveSourceConfiguration( from: command, simulatorResolution: simulatorResolution, @@ -379,7 +379,6 @@ func preparePrivateHeaderKitGenerationRequest( from: command, effectiveSource: effectiveSource, helperURLs: helperPlan.helperURLs, - toolCompatibilityIdentity: helperPlan.toolCompatibilityIdentity, simulatorResolution: simulatorResolution ) } @@ -434,7 +433,6 @@ func runPrivateHeaderKitPreparedGeneration( func makePrivateHeaderGenerationRequest( from command: PrivateHeaderKitGenerateCommand, helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs, - toolCompatibilityIdentity: String, simulatorResolution: PrivateHeaderKitSimulatorResolution?, releaseMetadataResolver: PrivateHeaderKitReleaseMetadataResolver = resolvePrivateHeaderKitReleaseMetadata @@ -448,7 +446,6 @@ func makePrivateHeaderGenerationRequest( from: command, effectiveSource: effectiveSource, helperURLs: helperURLs, - toolCompatibilityIdentity: toolCompatibilityIdentity, simulatorResolution: simulatorResolution ) } @@ -457,7 +454,6 @@ private func makePrivateHeaderGenerationRequest( from command: PrivateHeaderKitGenerateCommand, effectiveSource: EffectiveSourceConfiguration, helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs, - toolCompatibilityIdentity: String, simulatorResolution: PrivateHeaderKitSimulatorResolution? ) throws -> PrivateHeaderKitGenerationRequest { let source = effectiveSource.source @@ -473,7 +469,15 @@ private func makePrivateHeaderGenerationRequest( } executionMode = .simulator( deviceUDID: simulatorResolution.deviceUDID, - runtimeRoot: effectiveSource.systemRoot.path + sourceRuntimeRoot: effectiveSource.systemRoot.path, + runtime: .init( + version: simulatorResolution.runtimeVersion, + build: simulatorResolution.runtimeBuild, + identifier: simulatorResolution.runtimeIdentifier, + runtimeRoot: canonicalDirectoryURL( + path: simulatorResolution.resolvedRuntimeRoot + ).path + ) ) } let targetRequest: PrivateHeaderGeneration.TargetRequest = @@ -489,7 +493,7 @@ private func makePrivateHeaderGenerationRequest( helperEnvironment: ["PH_RUNTIME_ROOT": effectiveSource.systemRoot.path] ), resumeBehavior: command.resumeBehavior, - toolCompatibilityIdentity: toolCompatibilityIdentity + producerVersion: PrivateHeaderKitBuildInfo.version ) return PrivateHeaderKitGenerationRequest(source: source, output: output, options: options) } @@ -651,7 +655,6 @@ func resolvePrivateHeaderKitHelperPlan( ) return PrivateHeaderKitHelperPlan( helperURLs: preparedURLs, - toolCompatibilityIdentity: baseline.compatibilityIdentity, preparation: .installed(PrivateHeaderKitInstalledToolValidation( runningExecutableIdentity: runningExecutableIdentity, artifacts: artifacts, @@ -773,7 +776,6 @@ func resolvePrivateHeaderKitHelperPlan( ) return PrivateHeaderKitHelperPlan( helperURLs: preparedURLs, - toolCompatibilityIdentity: baseline.compatibilityIdentity, preparation: .swiftPM(PrivateHeaderKitSwiftPMToolPreparation( context: identityContext, baseline: baseline, @@ -1072,6 +1074,78 @@ private func currentHostSupportsNativeArm64Simulator() -> Bool { #endif } +func withPrivateHeaderKitSimulatorSession( + _ command: PrivateHeaderKitGenerateCommand, + resolver: PrivateHeaderKitSimulatorResolver, + cleaner: @escaping PrivateHeaderKitSimulatorCleaner, + outputLogger: @escaping PrivateHeaderKitOutputLogger, + operation: (PrivateHeaderKitSimulatorResolution?) async throws -> Result +) async throws -> Result { + let resolution: PrivateHeaderKitSimulatorResolution? + if command.platform.simulatorPlatform == nil { + resolution = nil + } else { + let resolved = try await resolver(command) + outputLogger("selected simulator: \(resolved.deviceName) (\(resolved.deviceUDID))") + resolution = resolved + } + + let result: Result + do { + result = try await operation(resolution) + } catch { + let operationError = error + if let resolution { + do { + try await finishPrivateHeaderKitSimulatorSession(resolution, cleaner: cleaner) + } catch { + throw PrivateHeaderKitSimulatorSessionCleanupError( + operationError: String(describing: operationError), + cleanupError: String(describing: error) + ) + } + } + throw operationError + } + if let resolution { + try await finishPrivateHeaderKitSimulatorSession(resolution, cleaner: cleaner) + } + return result +} + +private struct PrivateHeaderKitSimulatorSessionCleanupError: Error, CustomStringConvertible { + let operationError: String + let cleanupError: String + + var description: String { + "simulator cleanup failed after \(operationError): \(cleanupError)" + } +} + +private func finishPrivateHeaderKitSimulatorSession( + _ resolution: PrivateHeaderKitSimulatorResolution, + cleaner: @escaping PrivateHeaderKitSimulatorCleaner +) async throws { + guard resolution.deviceOwnership == .runOwned else { return } + try await Task.detached { + try await cleaner(resolution) + }.value +} + +func cleanupPrivateHeaderKitSimulator( + _ resolution: PrivateHeaderKitSimulatorResolution +) async throws { + guard resolution.deviceOwnership == .runOwned else { return } + try await Simctl.deleteDevice( + DeviceInfo( + name: resolution.deviceName, + udid: resolution.deviceUDID, + state: "Booted" + ), + runner: ProcessRunner() + ) +} + func resolvePrivateHeaderKitSimulator( for command: PrivateHeaderKitGenerateCommand ) async throws -> PrivateHeaderKitSimulatorResolution { @@ -1080,7 +1154,8 @@ func resolvePrivateHeaderKitSimulator( func resolvePrivateHeaderKitSimulator( for command: PrivateHeaderKitGenerateCommand, - runner: CommandRunning + runner: CommandRunning, + dedicatedDeviceName: String? = nil ) async throws -> PrivateHeaderKitSimulatorResolution { guard let simulatorPlatform = command.platform.simulatorPlatform else { throw PrivateHeaderKitCLIError.missingSimulatorResolution @@ -1101,15 +1176,16 @@ func resolvePrivateHeaderKitSimulator( build: runtime.build.isEmpty ? nil : runtime.build, metadataIsSeed: metadataIsSeed ) - let device = try await Simctl.resolveDevice( + let resolvedDevice = try await Simctl.resolveDevice( runtime: runtime, query: command.device, - runner: runner + runner: runner, + dedicatedDeviceName: dedicatedDeviceName ) return PrivateHeaderKitSimulatorResolution( runtime: runtime, metadataIsSeed: metadataIsSeed, - device: device + resolvedDevice: resolvedDevice ) } diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitGenerationClient.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitGenerationClient.swift index 41ae27b..5eab085 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitGenerationClient.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitGenerationClient.swift @@ -134,33 +134,13 @@ func runPrivateHeaderKitRawDump( ) } -enum PrivateHeaderKitRawDumpContractError: Error, Equatable, CustomStringConvertible { - case missingDiagnosticsReport(String) - case invalidDiagnosticsReport(path: String, reason: String) - case diagnosticsReportTooLarge(path: String, actual: Int, maximum: Int) - case diagnosticsReportCleanupFailed(path: String, reason: String) - - var description: String { - switch self { - case .missingDiagnosticsReport(let path): - "raw helper contract failure: successful helper did not write diagnostics report at \(path)" - case .invalidDiagnosticsReport(let path, let reason): - "raw helper contract failure: invalid diagnostics report at \(path): \(reason)" - case .diagnosticsReportTooLarge(let path, let actual, let maximum): - "raw helper contract failure: diagnostics report at \(path) is \(actual) bytes; maximum is \(maximum)" - case .diagnosticsReportCleanupFailed(let path, let reason): - "raw helper contract failure: could not remove diagnostics report at \(path): \(reason)" - } - } -} - private func consumeRawDumpDiagnosticsReport( at reportURL: URL, fileManager: FileManager = .default ) throws -> PrivateHeaderKitRawDumpDiagnosticsReport { let path = reportURL.path guard fileManager.fileExists(atPath: path) else { - throw PrivateHeaderKitRawDumpContractError.missingDiagnosticsReport(path) + throw PrivateHeaderGeneration.RawDumping.ContractError.missingDiagnosticsReport(path) } let data: Data @@ -170,19 +150,19 @@ private func consumeRawDumpDiagnosticsReport( .fileSizeKey, ]) guard values.isRegularFile == true else { - throw PrivateHeaderKitRawDumpContractError.invalidDiagnosticsReport( + throw PrivateHeaderGeneration.RawDumping.ContractError.invalidDiagnosticsReport( path: path, reason: "report is not a regular file" ) } guard let fileSize = values.fileSize else { - throw PrivateHeaderKitRawDumpContractError.invalidDiagnosticsReport( + throw PrivateHeaderGeneration.RawDumping.ContractError.invalidDiagnosticsReport( path: path, reason: "report size is unavailable" ) } guard fileSize <= PrivateHeaderKitRawDumpDiagnosticsReport.maximumEncodedByteCount else { - throw PrivateHeaderKitRawDumpContractError.diagnosticsReportTooLarge( + throw PrivateHeaderGeneration.RawDumping.ContractError.diagnosticsReportTooLarge( path: path, actual: fileSize, maximum: PrivateHeaderKitRawDumpDiagnosticsReport.maximumEncodedByteCount @@ -190,18 +170,18 @@ private func consumeRawDumpDiagnosticsReport( } data = try Data(contentsOf: reportURL) guard data.count <= PrivateHeaderKitRawDumpDiagnosticsReport.maximumEncodedByteCount else { - throw PrivateHeaderKitRawDumpContractError.diagnosticsReportTooLarge( + throw PrivateHeaderGeneration.RawDumping.ContractError.diagnosticsReportTooLarge( path: path, actual: data.count, maximum: PrivateHeaderKitRawDumpDiagnosticsReport.maximumEncodedByteCount ) } - } catch let error as PrivateHeaderKitRawDumpContractError { + } catch let error as PrivateHeaderGeneration.RawDumping.ContractError { try? fileManager.removeItem(at: reportURL) throw error } catch { try? fileManager.removeItem(at: reportURL) - throw PrivateHeaderKitRawDumpContractError.invalidDiagnosticsReport( + throw PrivateHeaderGeneration.RawDumping.ContractError.invalidDiagnosticsReport( path: path, reason: String(describing: error) ) @@ -215,7 +195,7 @@ private func consumeRawDumpDiagnosticsReport( ) } catch { try? fileManager.removeItem(at: reportURL) - throw PrivateHeaderKitRawDumpContractError.invalidDiagnosticsReport( + throw PrivateHeaderGeneration.RawDumping.ContractError.invalidDiagnosticsReport( path: path, reason: String(describing: error) ) @@ -224,7 +204,7 @@ private func consumeRawDumpDiagnosticsReport( do { try fileManager.removeItem(at: reportURL) } catch { - throw PrivateHeaderKitRawDumpContractError.diagnosticsReportCleanupFailed( + throw PrivateHeaderGeneration.RawDumping.ContractError.diagnosticsReportCleanupFailed( path: path, reason: String(describing: error) ) diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift index 9ab848c..aecb427 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift @@ -46,7 +46,7 @@ struct PrivateHeaderKitInteractiveSource: Equatable, Sendable { } } -private enum PrivateHeaderKitInteractiveNavigation: Error { +enum PrivateHeaderKitInteractiveNavigation: Error { case back } @@ -75,6 +75,7 @@ func runPrivateHeaderKitInteractiveGenerate( currentExecutableURL: URL?, generationClient: PrivateHeaderKitGenerationClient, simulatorResolver: PrivateHeaderKitSimulatorResolver, + simulatorCleaner: @escaping PrivateHeaderKitSimulatorCleaner, helperResolver: PrivateHeaderKitHelperResolver, releaseMetadataResolver: PrivateHeaderKitReleaseMetadataResolver, sourceProvider: PrivateHeaderKitInteractiveSourceProvider, @@ -162,34 +163,40 @@ func runPrivateHeaderKitInteractiveGenerate( simulatorHelperPath: nil ) do { - let request = try await preparePrivateHeaderKitGenerationRequest( + return try await withPrivateHeaderKitSimulatorSession( command, - invokedProgramName: invokedProgramName, - currentExecutableURL: currentExecutableURL, - simulatorResolver: simulatorResolver, - helperResolver: helperResolver, - releaseMetadataResolver: releaseMetadataResolver, + resolver: simulatorResolver, + cleaner: simulatorCleaner, outputLogger: outputLogger - ) - let preparedGeneration = try await generationClient.prepare(request) - let resumeBehavior = try await interactiveResumeDecision( - preparedGeneration: preparedGeneration, - request: request, - outputBaseDirectory: command.outputBaseDirectory, - screenClearer: screenClearer, - inputReader: inputReader, - outputLogger: outputLogger - ) - try await inputFinalizer() - return try await runPrivateHeaderKitPreparedGeneration( - preparedGeneration, - request: request, - targetQuery: command.targetQuery, - resumeBehavior: resumeBehavior, - resultScreenClearer: screenClearer, - outputLogger: outputLogger, - errorLogger: errorLogger - ) + ) { simulatorResolution in + let request = try await preparePrivateHeaderKitGenerationRequest( + command, + invokedProgramName: invokedProgramName, + currentExecutableURL: currentExecutableURL, + simulatorResolution: simulatorResolution, + helperResolver: helperResolver, + releaseMetadataResolver: releaseMetadataResolver + ) + let preparedGeneration = try await generationClient.prepare(request) + let resumeBehavior = try await interactiveResumeDecision( + preparedGeneration: preparedGeneration, + request: request, + outputBaseDirectory: command.outputBaseDirectory, + screenClearer: screenClearer, + inputReader: inputReader, + outputLogger: outputLogger + ) + try await inputFinalizer() + return try await runPrivateHeaderKitPreparedGeneration( + preparedGeneration, + request: request, + targetQuery: command.targetQuery, + resumeBehavior: resumeBehavior, + resultScreenClearer: screenClearer, + outputLogger: outputLogger, + errorLogger: errorLogger + ) + } } catch PrivateHeaderKitInteractiveNavigation.back { continue targetSelection } diff --git a/Sources/PrivateHeaderKitCore/PrivateHeaderGeneration.swift b/Sources/PrivateHeaderKitCore/PrivateHeaderGeneration.swift index 4f5ffc8..d37410f 100644 --- a/Sources/PrivateHeaderKitCore/PrivateHeaderGeneration.swift +++ b/Sources/PrivateHeaderKitCore/PrivateHeaderGeneration.swift @@ -1,4 +1,5 @@ import Foundation +import PrivateHeaderKitHelperProtocol #if canImport(Darwin) import Darwin @@ -441,7 +442,7 @@ extension PrivateHeaderGeneration { package var rawDumpingOptions: RawDumping.Options package var includeNestedChildren: Bool package var resumeBehavior: ResumeBehavior - package var toolCompatibilityIdentity: String + package var producerVersion: String package init( layout: Layout = .headers, @@ -452,7 +453,7 @@ extension PrivateHeaderGeneration { rawDumpingOptions: RawDumping.Options = RawDumping.Options(), includeNestedChildren: Bool = true, resumeBehavior: ResumeBehavior = .requireExplicitResume(resumeRequested: false), - toolCompatibilityIdentity: String + producerVersion: String = PrivateHeaderKitBuildInfo.version ) { self.layout = layout self.targetRequest = targetRequest @@ -462,7 +463,7 @@ extension PrivateHeaderGeneration { self.rawDumpingOptions = rawDumpingOptions self.includeNestedChildren = includeNestedChildren self.resumeBehavior = resumeBehavior - self.toolCompatibilityIdentity = toolCompatibilityIdentity + self.producerVersion = producerVersion } } @@ -561,6 +562,7 @@ extension PrivateHeaderGeneration { package enum GenerationError: Error, Equatable, CustomStringConvertible, Sendable { case missingExecutionConfiguration(String) + case producerVersionMismatch(expected: String, actual: String) case emptySharedCacheInventory(cacheUUID: UUID) case sharedCacheCohortChanged( expectedUUID: UUID, @@ -583,6 +585,8 @@ extension PrivateHeaderGeneration { switch self { case .missingExecutionConfiguration(let field): "private header generation requires \(field)" + case .producerVersionMismatch(let expected, let actual): + "private header helper version mismatch (expected \(expected), actual \(actual))" case .emptySharedCacheInventory(let cacheUUID): "loaded shared cache \(cacheUUID.uuidString.lowercased()) contains no images" case .sharedCacheCohortChanged( diff --git a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationExecutor.swift b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationExecutor.swift index 3242963..341e935 100644 --- a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationExecutor.swift +++ b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationExecutor.swift @@ -4,6 +4,14 @@ import PrivateHeaderKitHelperProtocol extension PrivateHeaderGeneration { package struct GenerationExecutor: Sendable { + private struct ProducerVersionMismatch: Error, CustomStringConvertible, Sendable { + let expected: String + let actual: String + + var description: String { + "private header helper version mismatch (expected \(expected), actual \(actual))" + } + } package static let maximumPresentedObjCMetadataWarningCount = 256 private struct DeliberateFault: Error, @unchecked Sendable { @@ -156,7 +164,6 @@ extension PrivateHeaderGeneration { let injectedStoreFault = storeFaultInjector let store = try GenerationStore( databaseURL: databaseURL, - toolCompatibilityIdentity: options.toolCompatibilityIdentity, faultInjector: { point in do { try injectedStoreFault(point) @@ -362,8 +369,7 @@ extension PrivateHeaderGeneration.GenerationExecutor { let runPlan = PrivateHeaderGeneration.RunPlan( sourceIdentity: plan.source.storageIdentifier, fingerprint: fingerprint, - targetIDs: targetIDs, - toolCompatibilityIdentity: plan.options.toolCompatibilityIdentity + targetIDs: targetIDs ) _ = try await store.beginRun(id: runID, plan: runPlan, at: dateProvider()) progressReporter?(.runStarted(runID: runID, totalTargetCount: targetIDsToRun.count)) @@ -1025,6 +1031,8 @@ extension PrivateHeaderGeneration.GenerationExecutor { artifactRoot: nil, warnings: [] ) + } catch let error as PrivateHeaderGeneration.RawDumping.ContractError { + throw error } catch { return Self.failedExecution( target: target, @@ -1034,6 +1042,13 @@ extension PrivateHeaderGeneration.GenerationExecutor { ) } + guard rawResult.diagnosticsReport.producerVersion == plan.options.producerVersion else { + throw ProducerVersionMismatch( + expected: plan.options.producerVersion, + actual: rawResult.diagnosticsReport.producerVersion + ) + } + if cancellationRequested() { return TargetExecution( result: Self.interruptedResult(target: target, at: dateProvider()), @@ -1420,6 +1435,7 @@ extension PrivateHeaderGeneration.GenerationExecutor { let actualCohort = try await Self.loadSharedCacheCohort( helperURLs: helperURLs, executionMode: executionMode, + expectedProducerVersion: preparedPlan.plan.options.producerVersion, helperEnvironment: preparedPlan.plan.options.rawDumpingOptions.helperEnvironment, sharedCacheInventoryRunner: sharedCacheInventoryRunner ) @@ -1499,6 +1515,7 @@ extension PrivateHeaderGeneration.GenerationExecutor { sharedCacheCohort = try await loadSharedCacheCohort( helperURLs: helperURLs, executionMode: executionMode, + expectedProducerVersion: plan.options.producerVersion, helperEnvironment: plan.options.rawDumpingOptions.helperEnvironment, sharedCacheInventoryRunner: sharedCacheInventoryRunner ) @@ -1530,6 +1547,7 @@ extension PrivateHeaderGeneration.GenerationExecutor { fileprivate static func loadSharedCacheCohort( helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs, executionMode: PrivateHeaderGeneration.RawDumping.ExecutionMode, + expectedProducerVersion: String, helperEnvironment: [String: String], sharedCacheInventoryRunner: SharedCacheInventoryRunner ) async throws -> SharedCacheCohort { @@ -1545,6 +1563,12 @@ extension PrivateHeaderGeneration.GenerationExecutor { PrivateHeaderKitSharedCacheInventory.self, from: data ) + guard inventory.producerVersion == expectedProducerVersion else { + throw PrivateHeaderGeneration.GenerationError.producerVersionMismatch( + expected: expectedProducerVersion, + actual: inventory.producerVersion + ) + } guard !inventory.imagePaths.isEmpty else { throw PrivateHeaderGeneration.GenerationError.emptySharedCacheInventory( cacheUUID: inventory.cacheUUID @@ -1588,8 +1612,7 @@ extension PrivateHeaderGeneration.GenerationExecutor { throw PrivateHeaderGeneration.GenerationError.legacyMigrationRequiresFresh(requirement) } let store = try GenerationStore( - databaseURL: databaseURL, - toolCompatibilityIdentity: plan.options.toolCompatibilityIdentity + databaseURL: databaseURL ) try await bootstrapAndRecover( sourceIdentity: plan.source.storageIdentifier, @@ -1935,19 +1958,17 @@ extension PrivateHeaderGeneration.GenerationExecutor { sharedCacheCohort: SharedCacheCohort? ) -> String { var components = [ - "privateheaderkit-plan-fingerprint-v2", + "privateheaderkit-plan-fingerprint-v3", plan.source.storageIdentifier, canonicalOutputBase.path, plan.options.layout.rawValue, plan.options.systemRoot?.standardizedFileURL.path ?? "", - plan.options.toolCompatibilityIdentity, + plan.options.producerVersion, String(plan.options.includeNestedChildren), String(plan.options.rawDumpingOptions.skipExisting), String(plan.options.rawDumpingOptions.useSharedCache), String(plan.options.rawDumpingOptions.verbose), String(plan.options.rawDumpingOptions.preferRuntimeMetadata), - plan.options.helperURLs?.host.standardizedFileURL.path ?? "", - plan.options.helperURLs?.simulator.standardizedFileURL.path ?? "", ] if let sharedCacheCohort { components += [ @@ -1962,8 +1983,15 @@ extension PrivateHeaderGeneration.GenerationExecutor { switch executionMode { case .host: components.append("host") - case .simulator(let deviceUDID, let runtimeRoot): - components += ["simulator", deviceUDID, runtimeRoot] + case .simulator(_, let sourceRuntimeRoot, let runtime): + components += [ + "simulator", + sourceRuntimeRoot, + runtime.version, + runtime.build, + runtime.identifier, + runtime.runtimeRoot, + ] } for key in plan.options.rawDumpingOptions.helperEnvironment.keys.sorted() { components += [ diff --git a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationRawDumping.swift b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationRawDumping.swift index c23ee96..4826033 100644 --- a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationRawDumping.swift +++ b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationRawDumping.swift @@ -37,7 +37,7 @@ extension PrivateHeaderGeneration { switch executionMode { case .host: commandPrefix = [helperURL.path] - case .simulator(let deviceUDID, _): + case .simulator(let deviceUDID, _, _): commandPrefix = [ "xcrun", "simctl", @@ -72,7 +72,7 @@ extension PrivateHeaderGeneration { "-o", request.stagingOutputDirectory.path, ] - case .simulator(let deviceUDID, _): + case .simulator(let deviceUDID, _, _): command = [ "xcrun", "simctl", @@ -115,7 +115,7 @@ extension PrivateHeaderGeneration { executionMode: ExecutionMode ) -> [String: String] { var environment = helperEnvironment - if case .simulator(_, let runtimeRoot) = executionMode { + if case .simulator(_, let runtimeRoot, _) = executionMode { environment["SIMCTL_CHILD_PH_RUNTIME_ROOT"] = runtimeRoot environment["SIMCTL_CHILD_DYLD_ROOT_PATH"] = runtimeRoot } @@ -125,6 +125,26 @@ extension PrivateHeaderGeneration { } extension PrivateHeaderGeneration.RawDumping { + package enum ContractError: Error, Equatable, CustomStringConvertible, Sendable { + case missingDiagnosticsReport(String) + case invalidDiagnosticsReport(path: String, reason: String) + case diagnosticsReportTooLarge(path: String, actual: Int, maximum: Int) + case diagnosticsReportCleanupFailed(path: String, reason: String) + + package var description: String { + switch self { + case .missingDiagnosticsReport(let path): + "raw helper contract failure: successful helper did not write diagnostics report at \(path)" + case .invalidDiagnosticsReport(let path, let reason): + "raw helper contract failure: invalid diagnostics report at \(path): \(reason)" + case .diagnosticsReportTooLarge(let path, let actual, let maximum): + "raw helper contract failure: diagnostics report at \(path) is \(actual) bytes; maximum is \(maximum)" + case .diagnosticsReportCleanupFailed(let path, let reason): + "raw helper contract failure: could not remove diagnostics report at \(path): \(reason)" + } + } + } + package struct HelperURLs: Hashable, Sendable { package let host: URL package let simulator: URL @@ -135,9 +155,27 @@ extension PrivateHeaderGeneration.RawDumping { } } + package struct SimulatorRuntimeIdentity: Hashable, Sendable { + package let version: String + package let build: String + package let identifier: String + package let runtimeRoot: String + + package init(version: String, build: String, identifier: String, runtimeRoot: String) { + self.version = version + self.build = build + self.identifier = identifier + self.runtimeRoot = runtimeRoot + } + } + package enum ExecutionMode: Hashable, Sendable { case host - case simulator(deviceUDID: String, runtimeRoot: String) + case simulator( + deviceUDID: String, + sourceRuntimeRoot: String, + runtime: SimulatorRuntimeIdentity + ) fileprivate var isHost: Bool { if case .host = self { return true } diff --git a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationState.swift b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationState.swift index b094ffe..2e4cce3 100644 --- a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationState.swift +++ b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationState.swift @@ -124,18 +124,15 @@ extension PrivateHeaderGeneration { package let sourceIdentity: String package let fingerprint: String package let targetIDs: [String] - package let toolCompatibilityIdentity: String package init( sourceIdentity: String, fingerprint: String, - targetIDs: [String], - toolCompatibilityIdentity: String + targetIDs: [String] ) { self.sourceIdentity = sourceIdentity self.fingerprint = fingerprint self.targetIDs = targetIDs - self.toolCompatibilityIdentity = toolCompatibilityIdentity } } diff --git a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationStore.swift b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationStore.swift index a2594d4..29a7b90 100644 --- a/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationStore.swift +++ b/Sources/PrivateHeaderKitCore/PrivateHeaderGenerationStore.swift @@ -42,7 +42,6 @@ package actor GenerationStore { package init( databaseURL: URL, - toolCompatibilityIdentity: String, faultInjector: @escaping FaultInjector = { _ in } ) throws { guard databaseURL.isFileURL else { @@ -63,16 +62,6 @@ package actor GenerationStore { ) } try Self.migrator.migrate(queue) - try queue.write { db in - try db.execute( - sql: """ - INSERT INTO metadata(key, value) VALUES ('toolCompatibilityIdentity', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - """, - arguments: [toolCompatibilityIdentity] - ) - } - databaseQueue = queue self.faultInjector = faultInjector } diff --git a/Sources/PrivateHeaderKitHelperProtocol/PrivateHeaderKitHelperProtocol.swift b/Sources/PrivateHeaderKitHelperProtocol/PrivateHeaderKitHelperProtocol.swift index 1b05ced..9a44b14 100644 --- a/Sources/PrivateHeaderKitHelperProtocol/PrivateHeaderKitHelperProtocol.swift +++ b/Sources/PrivateHeaderKitHelperProtocol/PrivateHeaderKitHelperProtocol.swift @@ -5,6 +5,31 @@ package enum PrivateHeaderKitHelperCommand: String, Sendable { case sharedCacheInventory = "__shared-cache-inventory" } +package enum PrivateHeaderKitProducerVersion { + package static let maximumUTF8Count = 256 + + package static func validated(_ value: String) throws -> String { + guard !value.isEmpty, + value.utf8.count <= maximumUTF8Count, + value.unicodeScalars.allSatisfy({ scalar in + switch scalar.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: + false + default: + true + } + }) + else { + throw ValidationError.invalid + } + return value + } + + package enum ValidationError: Error, Equatable, Sendable { + case invalid + } +} + package struct PrivateHeaderKitRawDumpDiagnostic: Codable, Hashable, Sendable { package static let maximumStringUTF8Count = 2_048 @@ -86,20 +111,27 @@ package struct PrivateHeaderKitRawDumpDiagnostic: Codable, Hashable, Sendable { } package struct PrivateHeaderKitRawDumpDiagnosticsReport: Codable, Hashable, Sendable { - package static let currentSchemaVersion = 1 + package static let currentSchemaVersion = 2 package static let maximumDiagnosticCount = 256 package static let maximumEncodedByteCount = 4 * 1_024 * 1_024 package let schemaVersion: Int + package let producerVersion: String package let diagnostics: [PrivateHeaderKitRawDumpDiagnostic] package let omittedDiagnosticCount: UInt package init( + producerVersion: String = PrivateHeaderKitBuildInfo.version, diagnostics: [PrivateHeaderKitRawDumpDiagnostic], omittedDiagnosticCount: UInt = 0 ) { let normalized = Array(Set(diagnostics)).sorted(by: Self.areInIncreasingOrder) + precondition( + (try? PrivateHeaderKitProducerVersion.validated(producerVersion)) != nil, + "producer version must satisfy the helper wire contract" + ) self.schemaVersion = Self.currentSchemaVersion + self.producerVersion = producerVersion self.diagnostics = Array(normalized.prefix(Self.maximumDiagnosticCount)) let existingOmittedCount = omittedDiagnosticCount let newlyOmittedCount = UInt(max(0, normalized.count - Self.maximumDiagnosticCount)) @@ -118,6 +150,9 @@ package struct PrivateHeaderKitRawDumpDiagnosticsReport: Codable, Hashable, Send actual: schemaVersion ) } + let producerVersion = try PrivateHeaderKitProducerVersion.validated( + container.decode(String.self, forKey: .producerVersion) + ) let diagnostics = try container.decode( [PrivateHeaderKitRawDumpDiagnostic].self, forKey: .diagnostics @@ -137,6 +172,7 @@ package struct PrivateHeaderKitRawDumpDiagnosticsReport: Codable, Hashable, Send throw ValidationError.nonCanonicalDiagnostics } self.schemaVersion = schemaVersion + self.producerVersion = producerVersion self.diagnostics = diagnostics self.omittedDiagnosticCount = omittedDiagnosticCount } @@ -151,6 +187,7 @@ package struct PrivateHeaderKitRawDumpDiagnosticsReport: Codable, Hashable, Send private enum CodingKeys: String, CodingKey { case schemaVersion + case producerVersion case diagnostics case omittedDiagnosticCount } @@ -163,14 +200,20 @@ package struct PrivateHeaderKitRawDumpDiagnosticsReport: Codable, Hashable, Send } package struct PrivateHeaderKitSharedCacheInventory: Codable, Equatable, Sendable { - package static let currentSchemaVersion = 1 + package static let currentSchemaVersion = 2 package let schemaVersion: Int + package let producerVersion: String package let cacheUUID: UUID package let imagePaths: [String] - package init(cacheUUID: UUID, imagePaths: [String]) throws { + package init( + producerVersion: String = PrivateHeaderKitBuildInfo.version, + cacheUUID: UUID, + imagePaths: [String] + ) throws { self.schemaVersion = Self.currentSchemaVersion + self.producerVersion = try PrivateHeaderKitProducerVersion.validated(producerVersion) self.cacheUUID = cacheUUID self.imagePaths = try Self.validatedImagePaths(imagePaths) } @@ -186,6 +229,9 @@ package struct PrivateHeaderKitSharedCacheInventory: Codable, Equatable, Sendabl } self.schemaVersion = schemaVersion + self.producerVersion = try PrivateHeaderKitProducerVersion.validated( + container.decode(String.self, forKey: .producerVersion) + ) self.cacheUUID = try container.decode(UUID.self, forKey: .cacheUUID) self.imagePaths = try Self.validatedImagePaths( container.decode([String].self, forKey: .imagePaths) @@ -195,6 +241,7 @@ package struct PrivateHeaderKitSharedCacheInventory: Codable, Equatable, Sendabl package func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(producerVersion, forKey: .producerVersion) try container.encode(cacheUUID, forKey: .cacheUUID) try container.encode(imagePaths, forKey: .imagePaths) } @@ -221,6 +268,7 @@ package struct PrivateHeaderKitSharedCacheInventory: Codable, Equatable, Sendabl private enum CodingKeys: String, CodingKey { case schemaVersion + case producerVersion case cacheUUID case imagePaths } diff --git a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift index 72542ec..f6d2c56 100644 --- a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift +++ b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift @@ -356,6 +356,10 @@ func buildSourceCohort( runner: runner, fileManager: fileManager ) + let buildEnvironment = [ + "PRIVATEHEADERKIT_BUILD_VERSION": sourceBeforeBuild.effectiveVersion, + "PRIVATEHEADERKIT_BUILD_COMMIT": sourceBeforeBuild.effectiveCommit, + ] try await buildProducts( [ InstallArtifactName.publicCommand.rawValue, @@ -363,6 +367,7 @@ func buildSourceCohort( ], configuration: configuration, in: repoRoot, + environment: buildEnvironment, runner: runner ) let hostBinDirectory = try await resolveSwiftBinDir( @@ -397,6 +402,7 @@ func buildSourceCohort( configuration: configuration, scratchPath: simulatorScratchPath, sdkPath: simulatorSDKPath, + environment: buildEnvironment, runner: runner, simulatorHelperTriple: simulatorTriple ) @@ -448,6 +454,7 @@ func buildProducts( _ products: [String], configuration: BuildConfiguration, in directory: URL, + environment: [String: String], runner: CommandRunning ) async throws { for product in products { @@ -460,7 +467,7 @@ func buildProducts( "--product", product, ], - env: nil, + env: environment, cwd: directory ) try Task.checkCancellation() @@ -472,6 +479,7 @@ func buildSimulatorHelper( configuration: BuildConfiguration, scratchPath: URL, sdkPath: String, + environment: [String: String], runner: CommandRunning, simulatorHelperTriple: String ) async throws { @@ -490,7 +498,7 @@ func buildSimulatorHelper( "--product", InstallArtifactName.simulatorHelper.rawValue, ], - env: nil, + env: environment, cwd: directory ) try Task.checkCancellation() diff --git a/Sources/PrivateHeaderKitTooling/Simctl.swift b/Sources/PrivateHeaderKitTooling/Simctl.swift index 0bd9e36..6b6a445 100644 --- a/Sources/PrivateHeaderKitTooling/Simctl.swift +++ b/Sources/PrivateHeaderKitTooling/Simctl.swift @@ -57,6 +57,27 @@ public struct DeviceInfo: Codable, Equatable, Sendable { public let name: String public let udid: String public var state: String + + public init(name: String, udid: String, state: String) { + self.name = name + self.udid = udid + self.state = state + } +} + +package enum SimulatorDeviceOwnership: Equatable, Sendable { + case borrowed + case runOwned +} + +package struct ResolvedSimulatorDevice: Equatable, Sendable { + package let device: DeviceInfo + package let ownership: SimulatorDeviceOwnership + + package init(device: DeviceInfo, ownership: SimulatorDeviceOwnership) { + self.device = device + self.ownership = ownership + } } public enum Simctl { @@ -224,90 +245,53 @@ public enum Simctl { return nil } - public static func pickDefaultDevice(devices: [DeviceInfo]) throws -> DeviceInfo { - guard let first = devices.first else { - throw ToolingError.message("no devices available") - } - if let shutdown = devices.first(where: { stateEquals($0.state, "Shutdown") }) { - return shutdown - } - if let booted = devices.first(where: { stateEquals($0.state, "Booted") }) { - return booted - } - return first - } - - package static func defaultCloneName( - platform: SimulatorPlatform, - version: String - ) -> String { - "Dumping Device (\(platform.userFacingSourceName) \(version))" - } - - public static func cloneDevice(base: DeviceInfo, runtimeId: String, cloneName: String, runner: CommandRunning) async throws -> DeviceInfo { - print("Cloning simulator: \(base.name) -> \(cloneName)") - let output = try await runner.runCapture(["xcrun", "simctl", "clone", base.udid, cloneName], env: nil, cwd: nil) - - let udid = output.split(separator: "\n").reversed().first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }).map(String.init) ?? "" - if !udid.isEmpty { - return DeviceInfo(name: cloneName, udid: udid, state: "Shutdown") - } - - let refreshed = try await listDevices(runtimeId: runtimeId, runner: runner) - if let match = matchDevice(devices: refreshed, query: cloneName) { - return match - } - throw ToolingError.message("failed to determine cloned simulator udid") - } - - package static func resolveDefaultDevice( - runtime: RuntimeInfo, - devices: [DeviceInfo], - runner: CommandRunning - ) async throws -> DeviceInfo { - let cloneName = defaultCloneName( - platform: runtime.platform, - version: runtime.version - ) - if let clone = matchDevice(devices: devices, query: cloneName) { - return clone - } - let base = try pickDefaultDevice(devices: devices) - if base.name == cloneName { - return base - } - if !stateEquals(base.state, "Shutdown") { - return base - } - return try await cloneDevice(base: base, runtimeId: runtime.identifier, cloneName: cloneName, runner: runner) - } - package static func resolveDevice( runtime: RuntimeInfo, query: String?, runner: CommandRunning, - environment: [String: String] = ProcessInfo.processInfo.environment - ) async throws -> DeviceInfo { - var devices = try await listDevices(runtimeId: runtime.identifier, runner: runner) - if devices.isEmpty { - try await createDefaultDevice(runtime: runtime, runner: runner, environment: environment) - devices = try await listDevices(runtimeId: runtime.identifier, runner: runner) - } - - let selected: DeviceInfo + environment: [String: String] = ProcessInfo.processInfo.environment, + dedicatedDeviceName: String? = nil + ) async throws -> ResolvedSimulatorDevice { if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty { + let devices = try await listDevices(runtimeId: runtime.identifier, runner: runner) guard let match = matchDevice(devices: devices, query: query) else { throw ToolingError.message( "simulator device not found for \(runtime.platform.userFacingSourceName) " + "\(runtime.version): \(query)" ) } - selected = match - } else { - selected = try await resolveDefaultDevice(runtime: runtime, devices: devices, runner: runner) + return ResolvedSimulatorDevice( + device: try await ensureDeviceBooted(match, runner: runner, force: false), + ownership: .borrowed + ) } - return try await ensureDeviceBooted(selected, runner: runner, force: false) + let createdName = dedicatedDeviceName + ?? "PrivateHeaderKit Dump (\(runtime.platform.userFacingSourceName) \(runtime.version)) " + + UUID().uuidString.lowercased() + let created = try await createDedicatedDevice( + runtime: runtime, + name: createdName, + runner: runner, + environment: environment + ) + do { + try Task.checkCancellation() + return ResolvedSimulatorDevice( + device: try await ensureDeviceBooted(created, runner: runner, force: false), + ownership: .runOwned + ) + } catch { + let cleanupResult = await Task.detached { + try await deleteDevice(created, runner: runner) + }.result + if case .failure(let cleanupError) = cleanupResult { + throw ToolingError.message( + "simulator acquisition failed: \(error); cleanup also failed: \(cleanupError)" + ) + } + throw error + } } public static func ensureDeviceBooted( @@ -324,11 +308,12 @@ public enum Simctl { return booted } - package static func createDefaultDevice( + package static func createDedicatedDevice( runtime: RuntimeInfo, + name: String, runner: CommandRunning, environment: [String: String] = ProcessInfo.processInfo.environment - ) async throws { + ) async throws -> DeviceInfo { let deviceTypes = try await defaultDeviceTypeCandidates(for: runtime, runner: runner) func matchesEnv(_ entry: DeviceTypeInfo, needle: String) -> Bool { @@ -365,9 +350,83 @@ public enum Simctl { throw ToolingError.message("no device types available") } - let createdName = "\(deviceName) (\(runtime.version))" - print("Creating device: \(createdName)") - try await runner.runSimple(["xcrun", "simctl", "create", createdName, deviceType, runtime.identifier], env: nil, cwd: nil) + print("Creating device: \(name)") + let output: String + do { + output = try await runner.runCapture( + ["xcrun", "simctl", "create", name, deviceType, runtime.identifier], + env: nil, + cwd: nil + ) + } catch { + let createError = error + let cleanupResult = await Task.detached { + try await deleteDedicatedDeviceIfPresent( + named: name, + runtimeID: runtime.identifier, + runner: runner + ) + }.result + if case .failure(let cleanupError) = cleanupResult { + throw ToolingError.message( + "simulator creation failed: \(createError); cleanup also failed: \(cleanupError)" + ) + } + throw createError + } + guard let value = output + .split(whereSeparator: \Character.isNewline) + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) + .last(where: { !$0.isEmpty }), + UUID(uuidString: value) != nil + else { + let contractError = ToolingError.message( + "simctl create did not return a simulator UDID" + ) + let cleanupResult = await Task.detached { + try await deleteDedicatedDeviceIfPresent( + named: name, + runtimeID: runtime.identifier, + runner: runner + ) + }.result + if case .failure(let cleanupError) = cleanupResult { + throw ToolingError.message( + "\(contractError); cleanup also failed: \(cleanupError)" + ) + } + throw contractError + } + return DeviceInfo(name: name, udid: value, state: "Shutdown") + } + + package static func deleteDevice( + _ device: DeviceInfo, + runner: CommandRunning + ) async throws { + print("Deleting simulator: \(device.name) (\(device.udid))") + try await runner.runSimple( + ["xcrun", "simctl", "delete", device.udid], + env: nil, + cwd: nil + ) + } + + private static func deleteDedicatedDeviceIfPresent( + named name: String, + runtimeID: String, + runner: CommandRunning + ) async throws { + let matches = try await listDevices(runtimeId: runtimeID, runner: runner) + .filter { $0.name == name } + guard matches.count <= 1 else { + throw ToolingError.message( + "multiple simulators matched the run-owned device name: \(name)" + ) + } + if let device = matches.first { + try await deleteDevice(device, runner: runner) + } } private static func defaultDeviceTypeCandidates(for runtime: RuntimeInfo, runner: CommandRunning) async throws -> [DeviceTypeInfo] { diff --git a/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift b/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift new file mode 100644 index 0000000..de305ec --- /dev/null +++ b/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing + +@testable import PrivateHeaderKitBuildInfoTool + +@Suite +struct PrivateHeaderKitBuildInfoToolTests { + @Test func environmentVersionWinsWithoutReadingGit() throws { + let version = try BuildVersionResolver.resolve( + environmentVersion: " v1.2.3 ", + packageDirectory: URL(fileURLWithPath: "/package"), + gitDescribe: { _ in + Issue.record("environment version must bypass Git") + return nil + } + ) + + #expect(version == "v1.2.3") + } + + @Test func gitIdentityIsRequiredWhenTheEnvironmentHasNoVersion() throws { + let version = try BuildVersionResolver.resolve( + environmentVersion: nil, + packageDirectory: URL(fileURLWithPath: "/package"), + gitDescribe: { _ in "a824233-dirty\n" } + ) + #expect(version == "a824233-dirty") + + #expect(throws: BuildInfoToolError.self) { + _ = try BuildVersionResolver.resolve( + environmentVersion: nil, + packageDirectory: URL(fileURLWithPath: "/package"), + gitDescribe: { _ in nil } + ) + } + } + + @Test func generatedSourceEscapesAValidSwiftStringLiteral() { + let source = BuildVersionResolver.generatedSource(version: #"v1.2.3-"quoted"\path"#) + + #expect(source.contains(#"package static let version = "v1.2.3-\"quoted\"\\path""#)) + } +} diff --git a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift index a88ed65..af58024 100644 --- a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift +++ b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift @@ -190,16 +190,131 @@ struct PrivateHeaderKitCLIArgumentTests { #expect(errors.text.contains("must not be empty")) #expect(errors.text.contains(option)) } + + let errors = ThreadSafeStrings() + let status = await runPrivateHeaderKitCommand( + [ + "privateheaderkit", + "--platform", "iOS", + "--version", "27.0", + "--out", "/tmp/headers", + "--target", "all", + "--device", " ", + ], + currentExecutableURL: nil, + outputLogger: { _ in }, + errorLogger: errors.append + ) + #expect(status != 0) + #expect(errors.text.contains("must not be empty")) + #expect(errors.text.contains("--device")) } } @Suite struct PrivateHeaderKitCLIExecutionTests { + @Test func simulatorSessionCleansRunOwnedDeviceAfterCancellationButNeverBorrowedDevice() async throws { + let cleanupCount = ThreadSafeCounter() + let operationStarted = EventCounter() + let cleanupWasCancelled = ThreadSafeBool() + let owned = PrivateHeaderKitSimulatorResolution( + runtimeVersion: testPrivateHeaderKitSimulatorResolution.runtimeVersion, + runtimeBuild: testPrivateHeaderKitSimulatorResolution.runtimeBuild, + runtimeIdentifier: testPrivateHeaderKitSimulatorResolution.runtimeIdentifier, + resolvedRuntimeRoot: testPrivateHeaderKitSimulatorResolution.resolvedRuntimeRoot, + metadataIsSeed: false, + deviceName: "PrivateHeaderKit Dump (iOS 27.0)", + deviceUDID: "11111111-2222-3333-4444-555555555555", + deviceOwnership: .runOwned + ) + + let cancellationTask = Task { + try await withPrivateHeaderKitSimulatorSession( + iosGenerateCommand(build: nil, systemRoot: nil), + resolver: { _ in owned }, + cleaner: { _ in + if Task.isCancelled { + cleanupWasCancelled.setTrue() + } + cleanupCount.increment() + }, + outputLogger: { _ in }, + operation: { _ in + operationStarted.signal() + while true { + try Task.checkCancellation() + await Task.yield() + } + } + ) + } + await operationStarted.wait(until: 1) + cancellationTask.cancel() + await #expect(throws: CancellationError.self) { + _ = try await cancellationTask.value + } + #expect(cleanupCount.value == 1) + #expect(!cleanupWasCancelled.value) + + let value: Int = try await withPrivateHeaderKitSimulatorSession( + iosGenerateCommand(build: nil, systemRoot: nil), + resolver: { _ in testPrivateHeaderKitSimulatorResolution }, + cleaner: { _ in cleanupCount.increment() }, + outputLogger: { _ in }, + operation: { _ in 7 } + ) + #expect(value == 7) + #expect(cleanupCount.value == 1) + + await #expect(throws: CLIFixtureError.self) { + let _: Int = try await withPrivateHeaderKitSimulatorSession( + iosGenerateCommand(build: nil, systemRoot: nil), + resolver: { _ in owned }, + cleaner: { _ in + cleanupCount.increment() + throw CLIFixtureError.cleanupFailed + }, + outputLogger: { _ in }, + operation: { _ in 9 } + ) + } + #expect(cleanupCount.value == 2) + } + + @Test func simulatorCleanupFailureOverridesInteractiveBack() async { + let owned = PrivateHeaderKitSimulatorResolution( + runtimeVersion: testPrivateHeaderKitSimulatorResolution.runtimeVersion, + runtimeBuild: testPrivateHeaderKitSimulatorResolution.runtimeBuild, + runtimeIdentifier: testPrivateHeaderKitSimulatorResolution.runtimeIdentifier, + resolvedRuntimeRoot: testPrivateHeaderKitSimulatorResolution.resolvedRuntimeRoot, + metadataIsSeed: false, + deviceName: "PrivateHeaderKit Dump (iOS 27.0) run-001", + deviceUDID: "11111111-2222-3333-4444-555555555555", + deviceOwnership: .runOwned + ) + + do { + let _: Int = try await withPrivateHeaderKitSimulatorSession( + iosGenerateCommand(build: nil, systemRoot: nil), + resolver: { _ in owned }, + cleaner: { _ in throw CLIFixtureError.cleanupFailed }, + outputLogger: { _ in }, + operation: { _ in throw PrivateHeaderKitInteractiveNavigation.back } + ) + Issue.record("interactive Back unexpectedly hid simulator cleanup failure") + } catch is PrivateHeaderKitInteractiveNavigation { + Issue.record("interactive Back unexpectedly won over simulator cleanup failure") + } catch { + #expect(String(describing: error).contains("simulator cleanup failed after")) + #expect(String(describing: error).contains("cleanupFailed")) + } + } + @Test func implicitWatchOSRuntimeUsesSimulatorFlowAndWatchStorageIdentity() throws { let request = try makePrivateHeaderGenerationRequest( from: watchOSGenerateCommand(build: nil, systemRoot: nil), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitWatchSimulatorResolution ) @@ -209,7 +324,10 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(request.options.systemRoot?.path == "/ResolvedWatchRuntime") #expect( request.options.executionMode - == .simulator(deviceUDID: "WATCH-001", runtimeRoot: "/ResolvedWatchRuntime") + == testExecutionMode( + resolution: testPrivateHeaderKitWatchSimulatorResolution, + sourceRuntimeRoot: "/ResolvedWatchRuntime" + ) ) } @@ -220,18 +338,13 @@ struct PrivateHeaderKitCLIExecutionTests { command, invokedProgramName: "privateheaderkit", currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), - simulatorResolver: { resolvedCommand in - #expect(resolvedCommand.platform == .watchOS) - return testPrivateHeaderKitWatchSimulatorResolution - }, + simulatorResolution: testPrivateHeaderKitWatchSimulatorResolution, helperResolver: { _, _, simulatorPlatform in #expect(simulatorPlatform == .watchOS) return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) - }, - outputLogger: { _ in } + } ) #expect(request.source.platform == .watchOS) @@ -241,7 +354,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: nil, systemRoot: nil), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver ) @@ -252,7 +365,10 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(request.options.rawDumpingOptions.useSharedCache) #expect( request.options.executionMode - == .simulator(deviceUDID: "SIM-001", runtimeRoot: "/ResolvedRuntime") + == testExecutionMode( + resolution: testPrivateHeaderKitSimulatorResolution, + sourceRuntimeRoot: "/ResolvedRuntime" + ) ) } @@ -269,7 +385,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: nil, systemRoot: nil), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: resolution ) @@ -289,30 +405,35 @@ struct PrivateHeaderKitCLIExecutionTests { let runner = RecordingCommandRunner() await runner.setCaptureOutput( """ - {"runtimes":[{"name":"iOS 27.0","platform":"iOS","version":"27.0","buildversion":"24A5390f","identifier":"ios-27","runtimeRoot":"\(runtimeRoot.path)","isAvailable":true}]} + {"runtimes":[{"name":"iOS 27.0","platform":"iOS","version":"27.0","buildversion":"24A5390f","identifier":"ios-27","runtimeRoot":"\(runtimeRoot.path)","isAvailable":true,"supportedDeviceTypes":[{"name":"iPhone 17","identifier":"com.apple.CoreSimulator.SimDeviceType.iPhone-17","productFamily":"iPhone"}]}]} """, for: ["xcrun", "simctl", "list", "runtimes", "-j"] ) - await runner.setCaptureOutput( - """ - {"devices":{"ios-27":[{"name":"Dumping Device (iOS 27.0)","udid":"SIM-001","state":"Booted"}]}} - """, - for: ["xcrun", "simctl", "list", "devices", "-j"] - ) + let deviceUDID = "11111111-2222-3333-4444-555555555555" + let createCommand = [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + await runner.setCaptureOutput(deviceUDID + "\n", for: createCommand) let resolution = try await resolvePrivateHeaderKitSimulator( for: iosGenerateCommand(build: nil, systemRoot: nil), - runner: runner + runner: runner, + dedicatedDeviceName: "PrivateHeaderKit Dump (iOS 27.0)" ) #expect(!resolution.metadataIsSeed) #expect(resolution.resolvedRuntimeRoot == runtimeRoot.path) - #expect(resolution.deviceUDID == "SIM-001") + #expect(resolution.deviceUDID == deviceUDID) + #expect(resolution.deviceOwnership == .runOwned) #expect(await runner.captureCommandSnapshot().map(\.command) == [ ["xcrun", "simctl", "list", "runtimes", "-j"], - ["xcrun", "simctl", "list", "devices", "-j"], + createCommand, + ]) + #expect(await runner.simpleCommandSnapshot().map(\.command) == [ + ["xcrun", "simctl", "boot", deviceUDID], + ["xcrun", "simctl", "bootstatus", deviceUDID, "-b"], ]) - #expect(await runner.simpleCommandSnapshot().isEmpty) } @Test func malformedSimulatorReleaseMetadataFailsBeforeResolvingADevice() async throws { @@ -347,7 +468,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: nil, systemRoot: "/OverrideRuntime"), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver ) @@ -358,7 +479,10 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(!request.options.rawDumpingOptions.useSharedCache) #expect( request.options.executionMode - == .simulator(deviceUDID: "SIM-001", runtimeRoot: "/OverrideRuntime") + == testExecutionMode( + resolution: testPrivateHeaderKitSimulatorResolution, + sourceRuntimeRoot: "/OverrideRuntime" + ) ) } @@ -373,7 +497,7 @@ struct PrivateHeaderKitCLIExecutionTests { _ = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: nil, systemRoot: "/OverrideSeedRuntime"), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution, releaseMetadataResolver: metadataResolver ) @@ -382,7 +506,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: "24A123", systemRoot: "/OverrideSeedRuntime"), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution, releaseMetadataResolver: metadataResolver ) @@ -405,7 +529,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: command, helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution, releaseMetadataResolver: { _, _ in false } ) @@ -418,7 +542,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: "24A999", systemRoot: nil), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution ) @@ -450,7 +574,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: nil, systemRoot: runtimeAlias.path), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: resolution ) let canonicalRuntimeRoot = runtimeRoot.resolvingSymlinksInPath().standardizedFileURL @@ -461,7 +585,10 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(request.options.rawDumpingOptions.useSharedCache) #expect( request.options.executionMode - == .simulator(deviceUDID: "SIM-001", runtimeRoot: canonicalRuntimeRoot.path) + == testExecutionMode( + resolution: resolution, + sourceRuntimeRoot: canonicalRuntimeRoot.path + ) ) #expect( request.options.rawDumpingOptions.helperEnvironment["PH_RUNTIME_ROOT"] @@ -473,7 +600,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try makePrivateHeaderGenerationRequest( from: iosGenerateCommand(build: "24A999", systemRoot: "/ResolvedRuntime"), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: testPrivateHeaderKitSimulatorResolution ) @@ -487,7 +614,7 @@ struct PrivateHeaderKitCLIExecutionTests { let currentRootRequest = try makePrivateHeaderGenerationRequest( from: macOSGenerateCommand(systemRoot: "/"), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: nil, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver ) @@ -501,7 +628,7 @@ struct PrivateHeaderKitCLIExecutionTests { let customRootRequest = try makePrivateHeaderGenerationRequest( from: macOSGenerateCommand(systemRoot: customRoot.path), helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity", + simulatorResolution: nil, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver ) @@ -520,22 +647,17 @@ struct PrivateHeaderKitCLIExecutionTests { macOSGenerateCommand(systemRoot: "/SeedSystemRoot"), invokedProgramName: "privateheaderkit", currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), - simulatorResolver: { _ in - Issue.record("macOS generation must not resolve a simulator") - return testPrivateHeaderKitSimulatorResolution - }, + simulatorResolution: nil, helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) }, releaseMetadataResolver: { _, layout in #expect(layout == .macOS) return true - }, - outputLogger: { _ in } + } ) } @@ -602,7 +724,7 @@ struct PrivateHeaderKitCLIExecutionTests { request.options.helperURLs?.simulator.path == "/cohort/privateheaderkit-sim-helper" ) - #expect(request.options.toolCompatibilityIdentity == "test-tool-identity:host") + #expect(request.options.producerVersion == PrivateHeaderKitBuildInfo.version) #expect(preparationCount.value == 1) #expect(summaryInspectionCount.value == 0) #expect(output.text.contains("Generated 2")) @@ -652,7 +774,7 @@ struct PrivateHeaderKitCLIExecutionTests { let request = try #require(requestBox.value) #expect(request.options.executionMode == .host) #expect(request.options.helperURLs?.simulator.path == customSimulatorHelper) - #expect(request.options.toolCompatibilityIdentity == "test-tool-identity:host") + #expect(request.options.producerVersion == PrivateHeaderKitBuildInfo.version) } @Test func runFailureUsesTypedSummaryWithoutReadingStateFiles() async { @@ -985,8 +1107,14 @@ struct PrivateHeaderKitCLIExecutionTests { let helperURL = root.appendingPathComponent("privateheaderkit-raw-helper") let inventoryCommand = [helperURL.path, "__shared-cache-inventory"] let runner = RecordingCommandRunner() + let inventoryData = try JSONEncoder().encode( + PrivateHeaderKitSharedCacheInventory( + cacheUUID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + imagePaths: ["/usr/lib/libCacheOnly.dylib"] + ) + ) await runner.setCaptureOutput( - #"{"schemaVersion":1,"cacheUUID":"11111111-2222-3333-4444-555555555555","imagePaths":["/usr/lib/libCacheOnly.dylib"]}"#, + String(decoding: inventoryData, as: UTF8.self), for: inventoryCommand ) await runner.setStreamingHandler { command, _, _ in @@ -1011,8 +1139,8 @@ struct PrivateHeaderKitCLIExecutionTests { else { throw ToolingError.message("raw dump command is missing its diagnostics report") } - try Data( - #"{"schemaVersion":1,"diagnostics":[],"omittedDiagnosticCount":0}"#.utf8 + try JSONEncoder().encode( + PrivateHeaderKitRawDumpDiagnosticsReport(diagnostics: []) ).write(to: URL(fileURLWithPath: command[reportIndex + 1]), options: .atomic) return StreamingCommandResult(status: 0, wasKilled: false, lastLines: []) } @@ -1038,8 +1166,7 @@ struct PrivateHeaderKitCLIExecutionTests { rawDumpingOptions: PrivateHeaderGeneration.RawDumping.Options( useSharedCache: true ), - resumeBehavior: .fresh, - toolCompatibilityIdentity: "test-tool-identity" + resumeBehavior: .fresh ) ) let prepared = try await PrivateHeaderKitGenerationClient @@ -1098,8 +1225,16 @@ struct PrivateHeaderKitCLIExecutionTests { guard let reportIndex = command.firstIndex(of: "--diagnostics-report") else { throw ToolingError.message("missing diagnostics report argument") } - try Data( - #"{"schemaVersion":1,"diagnostics":[{"owner":"Objective-C protocol P","degradation":"list was truncated"}],"omittedDiagnosticCount":2}"#.utf8 + try JSONEncoder().encode( + PrivateHeaderKitRawDumpDiagnosticsReport( + diagnostics: [ + PrivateHeaderKitRawDumpDiagnostic( + owner: "Objective-C protocol P", + degradation: "list was truncated" + ), + ], + omittedDiagnosticCount: 2 + ) ).write(to: URL(fileURLWithPath: command[reportIndex + 1]), options: .atomic) return StreamingCommandResult(status: 0, wasKilled: false, lastLines: []) } @@ -1138,7 +1273,7 @@ struct PrivateHeaderKitCLIExecutionTests { } } - await #expect(throws: PrivateHeaderKitRawDumpContractError.self) { + await #expect(throws: PrivateHeaderGeneration.RawDumping.ContractError.self) { _ = try await runPrivateHeaderKitRawDump(invocation, processRunner: runner) } #expect(!FileManager.default.fileExists(atPath: invocation.diagnosticsReportURL.path)) @@ -1166,7 +1301,7 @@ struct PrivateHeaderKitCLIExecutionTests { return StreamingCommandResult(status: 0, wasKilled: false, lastLines: []) } - await #expect(throws: PrivateHeaderKitRawDumpContractError.self) { + await #expect(throws: PrivateHeaderGeneration.RawDumping.ContractError.self) { _ = try await runPrivateHeaderKitRawDump(invocation, processRunner: runner) } #expect(!FileManager.default.fileExists(atPath: invocation.diagnosticsReportURL.path)) @@ -1335,8 +1470,7 @@ struct PrivateHeaderKitCLIExecutionTests { helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: helperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: helperURLs ) }, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, @@ -1362,7 +1496,7 @@ struct PrivateHeaderKitCLIExecutionTests { == .requireExplicitResume(resumeRequested: false) ) #expect(requestBox.value?.options.helperURLs == helperURLs) - #expect(requestBox.value?.options.toolCompatibilityIdentity == "test-tool-identity") + #expect(requestBox.value?.options.producerVersion == PrivateHeaderKitBuildInfo.version) #expect(helperResolutionCount.value == 1) #expect(output.text.contains("Step 1 of 3")) #expect(output.text.contains("Generation completed")) @@ -1442,8 +1576,7 @@ struct PrivateHeaderKitCLIExecutionTests { helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) }, interactiveSourceProvider: { @@ -1536,8 +1669,7 @@ struct PrivateHeaderKitCLIExecutionTests { helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) }, interactiveSourceProvider: { @@ -1614,8 +1746,7 @@ struct PrivateHeaderKitCLIExecutionTests { helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) }, interactiveSourceProvider: { @@ -1689,8 +1820,7 @@ struct PrivateHeaderKitCLIExecutionTests { helperResolver: { _, _, _ in helperResolutionCount.increment() return PrivateHeaderKitHelperPlan( - helperURLs: testPrivateHeaderKitHelperURLs, - toolCompatibilityIdentity: "test-tool-identity" + helperURLs: testPrivateHeaderKitHelperURLs ) }, interactiveSourceProvider: { @@ -1726,6 +1856,7 @@ struct PrivateHeaderKitCLIExecutionTests { let input = ScriptedInput(["1", "1", "2", "\u{001B}", "\u{001B}"]) let preparationCount = ThreadSafeCounter() let runCount = ThreadSafeCounter() + let cleanupCount = ThreadSafeCounter() let status = await runPrivateHeaderKitCommand( ["privateheaderkit"], currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), @@ -1744,15 +1875,28 @@ struct PrivateHeaderKitCLIExecutionTests { ) } ), + simulatorResolver: { _ in + PrivateHeaderKitSimulatorResolution( + runtimeVersion: "27.0", + runtimeBuild: "24A123", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + resolvedRuntimeRoot: "/ResolvedRuntime", + metadataIsSeed: false, + deviceName: "PrivateHeaderKit Dump (iOS 27.0)", + deviceUDID: "11111111-2222-3333-4444-555555555555", + deviceOwnership: .runOwned + ) + }, + simulatorCleaner: { _ in cleanupCount.increment() }, helperResolver: testPrivateHeaderKitHelperResolver, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, interactiveSourceProvider: { [ PrivateHeaderKitInteractiveSource( - platform: .macOS, - version: "16.0", - build: nil, - systemRoot: "/" + platform: .iOS, + version: "27.0", + build: "24A123", + systemRoot: nil ), ] }, @@ -1766,6 +1910,7 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(status == 1) #expect(preparationCount.value == 1) #expect(runCount.value == 0) + #expect(cleanupCount.value == 1) } } @@ -1798,6 +1943,25 @@ private let testPrivateHeaderKitWatchSimulatorResolution = PrivateHeaderKitSimul deviceUDID: "WATCH-001" ) +private func testExecutionMode( + resolution: PrivateHeaderKitSimulatorResolution, + sourceRuntimeRoot: String +) -> PrivateHeaderGeneration.RawDumping.ExecutionMode { + .simulator( + deviceUDID: resolution.deviceUDID, + sourceRuntimeRoot: sourceRuntimeRoot, + runtime: .init( + version: resolution.runtimeVersion, + build: resolution.runtimeBuild, + identifier: resolution.runtimeIdentifier, + runtimeRoot: URL( + fileURLWithPath: resolution.resolvedRuntimeRoot, + isDirectory: true + ).resolvingSymlinksInPath().standardizedFileURL.path + ) + ) +} + private func iosGenerateCommand( build: String?, systemRoot: String? @@ -1894,7 +2058,6 @@ struct PrivateHeaderKitHelperLookupTests { simulatorHelperPath: nil, simulatorPlatform: nil ) - #expect(plan.toolCompatibilityIdentity.hasPrefix("phk-tool-v1:artifacts:")) try await executePrivateHeaderKitHelperBuilds( plan, runner: RecordingCommandRunner() @@ -1982,7 +2145,6 @@ struct PrivateHeaderKitHelperLookupTests { plan.helperURLs.simulator.lastPathComponent == "privateheaderkit-sim-helper" ) #expect(plan.helperURLs.host.path.contains("/prepared-tools/v1/")) - #expect(plan.toolCompatibilityIdentity.hasPrefix("phk-tool-v1:swiftpm:")) #expect(!(await runner.captureCommandSnapshot()).contains { $0.command.contains("--product") }) @@ -2469,10 +2631,7 @@ private func testPrivateHeaderKitHelperResolver( helperURLs: PrivateHeaderGeneration.RawDumping.HelperURLs( host: host, simulator: simulator - ), - toolCompatibilityIdentity: simulatorPlatform != nil - ? "test-tool-identity:host-and-simulator" - : "test-tool-identity:host" + ) ) } @@ -3101,6 +3260,7 @@ private enum FailureKind { } private enum CLIFixtureError: Error { + case cleanupFailed case missingResumeSummary case unexpectedSharedCacheInventory } @@ -3295,8 +3455,7 @@ private func unfinishedResumeSummaryFixture() async throws systemRoot: systemRoot, helperURLs: testPrivateHeaderKitHelperURLs, executionMode: .host, - resumeBehavior: .requireExplicitResume(resumeRequested: false), - toolCompatibilityIdentity: "test-tool-identity" + resumeBehavior: .requireExplicitResume(resumeRequested: false) ) ) let executor = PrivateHeaderGeneration.GenerationExecutor( diff --git a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationExecutorTests.swift b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationExecutorTests.swift index b0d97b4..224e3c9 100644 --- a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationExecutorTests.swift +++ b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationExecutorTests.swift @@ -81,6 +81,41 @@ struct PrivateHeaderGenerationExecutorTests { #expect(preparedPlan.sharedCacheCohort == nil) } + @Test func inventoryProducerVersionMismatchFailsDuringPreparation() async throws { + let fixture = try ExecutorFixture() + defer { fixture.cleanup() } + try fixture.createFramework("Foo.framework") + let inventoryRunner = RecordingInventoryRunner( + data: try sharedCacheInventoryData( + producerVersion: "v0.9.0", + imagePaths: ["/System/Library/Frameworks/Foo.framework/Foo"] + ) + ) + let executor = fixture.executor( + runner: RecordingRunner(contents: "must-not-run"), + inventoryRunner: { invocation in try await inventoryRunner.run(invocation) }, + runID: "run-unused", + generationID: "generation-unused" + ) + + await #expect( + throws: PrivateHeaderGeneration.GenerationError.producerVersionMismatch( + expected: "v1.0.0", + actual: "v0.9.0" + ) + ) { + _ = try await executor.prepare( + try fixture.plan( + .query("Foo"), + rawDumpingOptions: .init(useSharedCache: true), + producerVersion: "v1.0.0" + ) + ) + } + + #expect(!FileManager.default.fileExists(atPath: fixture.outputBase.path)) + } + @Test func changedPreparedCacheCohortFailsBeforeLeaseStateOrRawDump() async throws { let fixture = try ExecutorFixture() defer { fixture.cleanup() } @@ -260,7 +295,7 @@ struct PrivateHeaderGenerationExecutorTests { let publication = try publisher.inspect() #expect(publication.currentGenerationID == .init(rawValue: "generation-001")) let store = try GenerationStore( - databaseURL: result.stateDatabaseURL, toolCompatibilityIdentity: "test") + databaseURL: result.stateDatabaseURL) #expect(try await store.runSnapshot(result.runID).status == .completed) #expect( try await store.targetSnapshot(targetID: "framework:Foo.framework")?.lastSuccessfulRunID @@ -425,7 +460,6 @@ struct PrivateHeaderGenerationExecutorTests { #expect(!FileManager.default.fileExists(atPath: fixture.liveHeaderURL(framework: "Foo").path)) let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) let snapshot = try await store.runSnapshot(.init(rawValue: "run-objc-warning-fault")) #expect(snapshot.status == .running) @@ -589,7 +623,6 @@ struct PrivateHeaderGenerationExecutorTests { #expect(!FileManager.default.fileExists(atPath: fixture.liveHeaderURL(framework: "Bar").path)) let firstStore = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) #expect( try await firstStore.targetSnapshot(targetID: "framework:Foo.framework")? @@ -1023,7 +1056,6 @@ struct PrivateHeaderGenerationExecutorTests { #expect(mutation.message == nil) let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) #expect( try await store.publicationIntent(generationID: .init(rawValue: "generation-aborted"))? @@ -1205,7 +1237,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.readLiveHeader() == "old") #expect(try fixture.readStableHeader() == "old") let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) #expect(try await store.runSnapshot(.init(rawValue: "run-002")).status == .partial) #expect( try await store.targetSnapshot(targetID: "framework:Foo.framework")?.lastSuccessfulRunID @@ -1229,10 +1261,88 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.publisher().inspect().currentGenerationID == nil) #expect(!FileManager.default.fileExists(atPath: fixture.stableURL.path)) let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) #expect(try await store.runSnapshot(.init(rawValue: "run-failed")).status == .failed) } + @Test func producerVersionMismatchStopsTheRunBeforeAnotherTargetOrPublication() async throws { + let fixture = try ExecutorFixture() + defer { fixture.cleanup() } + try fixture.createFramework("Foo.framework") + try fixture.createFramework("Bar.framework") + let runner = RecordingRunner( + contents: "generated", + result: .init( + terminationStatus: 0, + diagnosticsReport: PrivateHeaderKitRawDumpDiagnosticsReport( + producerVersion: "v0.9.0", + diagnostics: [] + ) + ) + ) + + do { + _ = try await fixture.executor( + runner: runner, + runID: "run-producer-mismatch", + generationID: "generation-producer-mismatch" + ).run( + plan: try fixture.plan( + .identifiers(["framework:Foo.framework", "framework:Bar.framework"]), + producerVersion: "v1.0.0" + ) + ) + Issue.record("producer version mismatch unexpectedly completed") + } catch let PrivateHeaderGeneration.GenerationError.infrastructureFailed(failure) { + #expect(failure.message.contains("expected v1.0.0, actual v0.9.0")) + #expect(failure.summary.status == .failed) + } + + #expect(await runner.invocationCount == 1) + #expect(try fixture.publisher().inspect().currentGenerationID == nil) + let store = try GenerationStore(databaseURL: fixture.databaseURL) + #expect( + try await store.runSnapshot(.init(rawValue: "run-producer-mismatch")).status == .failed + ) + } + + @Test func rawHelperContractFailureStopsTheRunBeforeAnotherTargetOrPublication() async throws { + let fixture = try ExecutorFixture() + defer { fixture.cleanup() } + try fixture.createFramework("Foo.framework") + try fixture.createFramework("Bar.framework") + let runner = RecordingRunner( + contents: nil, + thrownError: PrivateHeaderGeneration.RawDumping.ContractError.invalidDiagnosticsReport( + path: "/tmp/diagnostics.json", + reason: "unsupported schema" + ) + ) + + do { + _ = try await fixture.executor( + runner: runner, + runID: "run-helper-contract-failure", + generationID: "generation-helper-contract-failure" + ).run( + plan: try fixture.plan( + .identifiers(["framework:Foo.framework", "framework:Bar.framework"]) + ) + ) + Issue.record("raw helper contract failure unexpectedly completed") + } catch let PrivateHeaderGeneration.GenerationError.infrastructureFailed(failure) { + #expect(failure.message.contains("unsupported schema")) + #expect(failure.summary.status == .failed) + } + + #expect(await runner.invocationCount == 1) + #expect(try fixture.publisher().inspect().currentGenerationID == nil) + let store = try GenerationStore(databaseURL: fixture.databaseURL) + #expect( + try await store.runSnapshot(.init(rawValue: "run-helper-contract-failure")).status == .failed + ) + } + @Test func cancellationAfterOneCompletionPublishesOnlyCompletedTarget() async throws { let fixture = try ExecutorFixture() defer { fixture.cleanup() } @@ -1269,7 +1379,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.readStableHeader(framework: "Foo") == "generated") #expect(!FileManager.default.fileExists(atPath: fixture.stableHeaderURL(framework: "Bar").path)) let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) let run = try await store.runSnapshot(.init(rawValue: "run-cancelled")) #expect(run.status == .interrupted) let statuses = Dictionary(uniqueKeysWithValues: run.targets.map { ($0.targetID, $0.status) }) @@ -1323,7 +1433,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(await runner.invocationCount == 1) #expect(try fixture.readStableHeader() == "generated") let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) #expect(try await store.runSnapshot(.init(rawValue: "run-interrupted")).status == .interrupted) #expect( try await store.publicationIntent(generationID: .init(rawValue: "generation-interrupted"))? @@ -1456,7 +1566,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(await secondRunner.invocationCount == 0) #expect(try fixture.readStableHeader() == "recoverable") let store = try GenerationStore( - databaseURL: result.stateDatabaseURL, toolCompatibilityIdentity: "test") + databaseURL: result.stateDatabaseURL) #expect(try await store.runSnapshot(.init(rawValue: "run-001")).status == .completed) #expect( try await store.publicationIntent(generationID: .init(rawValue: "generation-001"))?.state @@ -1507,7 +1617,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.readLiveHeader() == "first-attempt") #expect(try fixture.readStableHeader() == "first-attempt") let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) let firstIntent = try #require( try await store.publicationIntent(generationID: .init(rawValue: "generation-001")) ) @@ -1566,7 +1676,6 @@ struct PrivateHeaderGenerationExecutorTests { ) let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) #expect( try await store.targetSnapshot(targetID: "framework:Foo.framework")?.lastSuccessfulRunID @@ -1675,7 +1784,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.readLiveHeader() == "new") #expect(try fixture.readStableHeader() == "old") let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) let run = try await store.runSnapshot(.init(rawValue: "run-002")) #expect(run.status == .failed) #expect(run.targets.first?.status == .completed) @@ -1757,7 +1866,6 @@ struct PrivateHeaderGenerationExecutorTests { let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) let staleRunID = PrivateHeaderGeneration.RunID(rawValue: "run-stale-foo") let staleDate = Date(timeIntervalSinceReferenceDate: 90) @@ -1767,7 +1875,6 @@ struct PrivateHeaderGenerationExecutorTests { sourceIdentity: fixture.source.storageIdentifier, fingerprint: "stale-attempt", targetIDs: ["framework:Foo.framework"], - toolCompatibilityIdentity: "test" ), at: staleDate ) @@ -2406,7 +2513,6 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try String(contentsOf: legacyArtifact, encoding: .utf8) == "legacy") let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) #expect(try await store.publishedArtifactsByTarget().isEmpty) } @@ -2442,7 +2548,6 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try String(contentsOf: legacyArtifact, encoding: .utf8) == "legacy") let store = try GenerationStore( databaseURL: fixture.databaseURL, - toolCompatibilityIdentity: "test" ) #expect(try await store.publishedArtifactsByTarget().isEmpty) } @@ -2489,7 +2594,7 @@ struct PrivateHeaderGenerationExecutorTests { #expect(try fixture.publisher().inspect().currentGenerationID == nil) let store = try GenerationStore( - databaseURL: fixture.databaseURL, toolCompatibilityIdentity: "test") + databaseURL: fixture.databaseURL) #expect(try await store.runSnapshot(.init(rawValue: "run-hidden")).status == .failed) } @@ -2998,7 +3103,8 @@ private struct ExecutorFixture { resumeBehavior: PrivateHeaderGeneration.ResumeBehavior = .requireExplicitResume( resumeRequested: false), outputBase: URL? = nil, - rawDumpingOptions: PrivateHeaderGeneration.RawDumping.Options = .init() + rawDumpingOptions: PrivateHeaderGeneration.RawDumping.Options = .init(), + producerVersion: String = PrivateHeaderKitBuildInfo.version ) throws -> PrivateHeaderGeneration.Plan { return PrivateHeaderGeneration.makePlan( source: source, @@ -3011,7 +3117,7 @@ private struct ExecutorFixture { executionMode: .host, rawDumpingOptions: rawDumpingOptions, resumeBehavior: resumeBehavior, - toolCompatibilityIdentity: "test" + producerVersion: producerVersion ) ) } @@ -3061,11 +3167,13 @@ private struct ExecutorFixture { } private func sharedCacheInventoryData( + producerVersion: String = PrivateHeaderKitBuildInfo.version, cacheUUID: UUID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, imagePaths: [String] ) throws -> Data { try JSONEncoder().encode( PrivateHeaderKitSharedCacheInventory( + producerVersion: producerVersion, cacheUUID: cacheUUID, imagePaths: imagePaths ) diff --git a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationRawDumpingTests.swift b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationRawDumpingTests.swift index c4ef60d..a1ef021 100644 --- a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationRawDumpingTests.swift +++ b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationRawDumpingTests.swift @@ -45,7 +45,7 @@ struct PrivateHeaderGenerationRawDumpingTests { let invocation = PrivateHeaderGeneration.RawDumping.makeInvocation( try .init( helperURLs: helperURLs, - executionMode: .simulator(deviceUDID: "SIM-001", runtimeRoot: runtimeRoot), + executionMode: simulatorExecutionMode(runtimeRoot: runtimeRoot), inputPath: "/System/Library/Frameworks/UIKit.framework", stagingOutputDirectory: stageDirectory, options: .init( @@ -91,7 +91,7 @@ struct PrivateHeaderGenerationRawDumpingTests { let runtimeRoot = "/Library/Developer/CoreSimulator/RuntimeRoot" let invocation = PrivateHeaderGeneration.RawDumping.makeSharedCacheInventoryInvocation( helperURLs: helperURLs, - executionMode: .simulator(deviceUDID: "SIM-001", runtimeRoot: runtimeRoot), + executionMode: simulatorExecutionMode(runtimeRoot: runtimeRoot), helperEnvironment: ["SIMCTL_CHILD_PH_PROFILE": "1"] ) @@ -143,3 +143,18 @@ struct PrivateHeaderGenerationRawDumpingTests { private let stageDirectory = URL( fileURLWithPath: "/tmp/PrivateHeaderKit/staging", isDirectory: true) } + +private func simulatorExecutionMode( + runtimeRoot: String +) -> PrivateHeaderGeneration.RawDumping.ExecutionMode { + .simulator( + deviceUDID: "SIM-001", + sourceRuntimeRoot: runtimeRoot, + runtime: .init( + version: "27.0", + build: "24A5355q", + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + runtimeRoot: runtimeRoot + ) + ) +} diff --git a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationStoreTests.swift b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationStoreTests.swift index 9135f19..2667710 100644 --- a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationStoreTests.swift +++ b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationStoreTests.swift @@ -21,7 +21,7 @@ struct PrivateHeaderGenerationStoreTests { } try migrator.migrate(queue) - let store = try GenerationStore(databaseURL: databaseURL, toolCompatibilityIdentity: "test") + let store = try GenerationStore(databaseURL: databaseURL) #expect( try await store.appliedMigrationIdentifiers() == [ "v1-generation-state", @@ -43,7 +43,7 @@ struct PrivateHeaderGenerationStoreTests { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let databaseURL = root.appendingPathComponent("generation.sqlite") - _ = try GenerationStore(databaseURL: databaseURL, toolCompatibilityIdentity: "test") + _ = try GenerationStore(databaseURL: databaseURL) let queue = try DatabaseQueue(path: databaseURL.path) try await queue.write { db in try db.execute( @@ -53,7 +53,7 @@ struct PrivateHeaderGenerationStoreTests { } do { - _ = try GenerationStore(databaseURL: databaseURL, toolCompatibilityIdentity: "test") + _ = try GenerationStore(databaseURL: databaseURL) Issue.record("future migration was unexpectedly accepted") } catch let error as PrivateHeaderGeneration.StateError { #expect(error == .unsupportedMigrations(["v999-future"])) @@ -667,7 +667,7 @@ struct PrivateHeaderGenerationStoreTests { try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) #expect(throws: PrivateHeaderGeneration.StateError.self) { - _ = try GenerationStore(databaseURL: link, toolCompatibilityIdentity: "test") + _ = try GenerationStore(databaseURL: link) } } @@ -675,7 +675,7 @@ struct PrivateHeaderGenerationStoreTests { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let databaseURL = root.appendingPathComponent("generation.sqlite") - let store = try GenerationStore(databaseURL: databaseURL, toolCompatibilityIdentity: "test") + let store = try GenerationStore(databaseURL: databaseURL) let queue = try DatabaseQueue(path: databaseURL.path) try await queue.write { db in try db.execute( @@ -798,7 +798,7 @@ private final class StoreFixture: @unchecked Sendable { root = try temporaryDirectory() databaseURL = root.appendingPathComponent("generation.sqlite") store = try GenerationStore( - databaseURL: databaseURL, toolCompatibilityIdentity: "test", faultInjector: fault) + databaseURL: databaseURL, faultInjector: fault) } func cleanup() { @@ -810,7 +810,6 @@ private final class StoreFixture: @unchecked Sendable { sourceIdentity: "iOS|27.0|24A", fingerprint: "fingerprint", targetIDs: targetIDs, - toolCompatibilityIdentity: "test" ) } diff --git a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationTests.swift b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationTests.swift index f111888..a679c3b 100644 --- a/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationTests.swift +++ b/Tests/PrivateHeaderKitCoreTests/PrivateHeaderGenerationTests.swift @@ -147,7 +147,7 @@ struct PrivateHeaderGenerationTests { let plan = PrivateHeaderGeneration.makePlan( source: source, output: output, - options: .init(toolCompatibilityIdentity: "test") + options: .init() ) #expect( @@ -171,7 +171,6 @@ struct PrivateHeaderGenerationTests { output: output, options: .init( systemRoot: URL(fileURLWithPath: "/runtime", isDirectory: true), - toolCompatibilityIdentity: "test" ) ) let second = PrivateHeaderGeneration.makePlan( @@ -179,7 +178,6 @@ struct PrivateHeaderGenerationTests { output: output, options: .init( systemRoot: URL(fileURLWithPath: "/foo\nheaders\n/runtime", isDirectory: true), - toolCompatibilityIdentity: "test" ) ) @@ -201,4 +199,106 @@ struct PrivateHeaderGenerationTests { #expect(firstFingerprint != secondFingerprint) } + + @Test func simulatorFingerprintUsesProducerAndRuntimeIdentityNotExecutionLocators() throws { + let source = try PrivateHeaderGeneration.Source( + platform: .iOS, + version: "27.0", + build: "24A5355q", + metadataIsSeed: true + ) + let output = PrivateHeaderGeneration.Output( + baseDirectory: URL(fileURLWithPath: "/tmp/PrivateHeaderKit", isDirectory: true) + ) + let runtime = PrivateHeaderGeneration.RawDumping.SimulatorRuntimeIdentity( + version: "27.0", + build: "24A5355q", + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + runtimeRoot: "/Runtime" + ) + let firstMode = PrivateHeaderGeneration.RawDumping.ExecutionMode.simulator( + deviceUDID: "11111111-2222-3333-4444-555555555555", + sourceRuntimeRoot: "/Runtime", + runtime: runtime + ) + let secondMode = PrivateHeaderGeneration.RawDumping.ExecutionMode.simulator( + deviceUDID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + sourceRuntimeRoot: "/Runtime", + runtime: runtime + ) + let firstPlan = PrivateHeaderGeneration.makePlan( + source: source, + output: output, + options: .init( + systemRoot: URL(fileURLWithPath: "/Runtime", isDirectory: true), + helperURLs: .init( + host: URL(fileURLWithPath: "/prepared/first/host"), + simulator: URL(fileURLWithPath: "/prepared/first/simulator") + ), + executionMode: firstMode, + producerVersion: "v1.0.0" + ) + ) + let relocatedPlan = PrivateHeaderGeneration.makePlan( + source: source, + output: output, + options: .init( + systemRoot: URL(fileURLWithPath: "/Runtime", isDirectory: true), + helperURLs: .init( + host: URL(fileURLWithPath: "/prepared/second/host"), + simulator: URL(fileURLWithPath: "/prepared/second/simulator") + ), + executionMode: secondMode, + producerVersion: "v1.0.0" + ) + ) + let changedProducerPlan = PrivateHeaderGeneration.makePlan( + source: source, + output: output, + options: .init( + systemRoot: URL(fileURLWithPath: "/Runtime", isDirectory: true), + helperURLs: relocatedPlan.options.helperURLs, + executionMode: secondMode, + producerVersion: "v1.1.0" + ) + ) + let outputBase = output.baseDirectory.standardizedFileURL + let first = PrivateHeaderGeneration.GenerationExecutor.planFingerprint( + firstPlan, + canonicalOutputBase: outputBase, + executionMode: firstMode, + sharedCacheCohort: nil + ) + let relocated = PrivateHeaderGeneration.GenerationExecutor.planFingerprint( + relocatedPlan, + canonicalOutputBase: outputBase, + executionMode: secondMode, + sharedCacheCohort: nil + ) + let changedProducer = PrivateHeaderGeneration.GenerationExecutor.planFingerprint( + changedProducerPlan, + canonicalOutputBase: outputBase, + executionMode: secondMode, + sharedCacheCohort: nil + ) + let changedRuntime = PrivateHeaderGeneration.GenerationExecutor.planFingerprint( + relocatedPlan, + canonicalOutputBase: outputBase, + executionMode: .simulator( + deviceUDID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + sourceRuntimeRoot: "/Runtime", + runtime: .init( + version: "27.0", + build: "24A9999z", + identifier: runtime.identifier, + runtimeRoot: runtime.runtimeRoot + ) + ), + sharedCacheCohort: nil + ) + + #expect(first == relocated) + #expect(first != changedProducer) + #expect(first != changedRuntime) + } } diff --git a/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift b/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift index dd4faae..4a142d0 100644 --- a/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift +++ b/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift @@ -6,14 +6,18 @@ import Testing @Suite struct PrivateHeaderKitHelperProtocolTests { @Test func rawDumpDiagnosticsZeroReportRoundTrips() throws { - let report = PrivateHeaderKitRawDumpDiagnosticsReport(diagnostics: []) + let report = PrivateHeaderKitRawDumpDiagnosticsReport( + producerVersion: "v1.2.3", + diagnostics: [] + ) let data = try JSONEncoder().encode(report) let decoded = try JSONDecoder().decode( PrivateHeaderKitRawDumpDiagnosticsReport.self, from: data ) - #expect(decoded.schemaVersion == 1) + #expect(decoded.schemaVersion == 2) + #expect(decoded.producerVersion == "v1.2.3") #expect(decoded.diagnostics.isEmpty) #expect(decoded.omittedDiagnosticCount == 0) } @@ -76,11 +80,12 @@ struct PrivateHeaderKitHelperProtocolTests { @Test func rawDumpDiagnosticsRejectsWrongVersionMalformedAndNoncanonicalPayloads() { let payloads = [ - #"{"schemaVersion":2,"diagnostics":[],"omittedDiagnosticCount":0}"#, + #"{"schemaVersion":3,"producerVersion":"v1.2.3","diagnostics":[],"omittedDiagnosticCount":0}"#, "not-json", - #"{"schemaVersion":1,"diagnostics":[{"owner":"b","degradation":"x"},{"owner":"a","degradation":"x"}],"omittedDiagnosticCount":0}"#, - #"{"schemaVersion":1,"diagnostics":[{"owner":"line\nowner","degradation":"x"}],"omittedDiagnosticCount":0}"#, - #"{"schemaVersion":1,"diagnostics":[],"omittedDiagnosticCount":-1}"#, + #"{"schemaVersion":2,"producerVersion":"v1.2.3","diagnostics":[{"owner":"b","degradation":"x"},{"owner":"a","degradation":"x"}],"omittedDiagnosticCount":0}"#, + #"{"schemaVersion":2,"producerVersion":"v1.2.3","diagnostics":[{"owner":"line\nowner","degradation":"x"}],"omittedDiagnosticCount":0}"#, + #"{"schemaVersion":2,"producerVersion":"v1.2.3","diagnostics":[],"omittedDiagnosticCount":-1}"#, + #"{"schemaVersion":2,"producerVersion":"","diagnostics":[],"omittedDiagnosticCount":0}"#, ] for payload in payloads { @@ -101,7 +106,7 @@ struct PrivateHeaderKitHelperProtocolTests { .joined(separator: ",") let data = Data( """ - {"schemaVersion":1,"diagnostics":[\(records)],"omittedDiagnosticCount":0} + {"schemaVersion":2,"producerVersion":"v1.2.3","diagnostics":[\(records)],"omittedDiagnosticCount":0} """.utf8 ) @@ -115,7 +120,7 @@ struct PrivateHeaderKitHelperProtocolTests { @Test func rawDumpDiagnosticsDecoderRejectsNegativeOmittedCount() { let data = Data( - #"{"schemaVersion":1,"diagnostics":[],"omittedDiagnosticCount":-1}"#.utf8 + #"{"schemaVersion":2,"producerVersion":"v1.2.3","diagnostics":[],"omittedDiagnosticCount":-1}"#.utf8 ) #expect(throws: DecodingError.self) { @@ -176,6 +181,7 @@ struct PrivateHeaderKitHelperProtocolTests { @Test func inventoryNormalizesImagePathMembershipAndRoundTrips() throws { let cacheUUID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! let inventory = try PrivateHeaderKitSharedCacheInventory( + producerVersion: "v1.2.3", cacheUUID: cacheUUID, imagePaths: [ "/usr/lib/libz.dylib", @@ -184,7 +190,8 @@ struct PrivateHeaderKitHelperProtocolTests { ] ) - #expect(inventory.schemaVersion == 1) + #expect(inventory.schemaVersion == 2) + #expect(inventory.producerVersion == "v1.2.3") #expect(inventory.imagePaths == [ "/usr/lib/libobjc.A.dylib", "/usr/lib/libz.dylib", @@ -200,7 +207,7 @@ struct PrivateHeaderKitHelperProtocolTests { @Test func inventoryRejectsUnsupportedSchemaDuringDecode() { let data = Data( """ - {"schemaVersion":2,"cacheUUID":"11111111-2222-3333-4444-555555555555","imagePaths":[]} + {"schemaVersion":3,"producerVersion":"v1.2.3","cacheUUID":"11111111-2222-3333-4444-555555555555","imagePaths":[]} """.utf8 ) diff --git a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift index 506fc9d..3959226 100644 --- a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift +++ b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift @@ -2172,6 +2172,12 @@ struct SourceBuildResolutionTests { ], ]) #expect(await runner.simpleCommandSnapshot().allSatisfy { $0.cwd == repoRoot }) + #expect(await runner.simpleCommandSnapshot().allSatisfy { + $0.env == [ + "PRIVATEHEADERKIT_BUILD_VERSION": "0.0.0-dev.aaaaaaaaaaaa", + "PRIVATEHEADERKIT_BUILD_COMMIT": String(repeating: "a", count: 40), + ] + }) #expect(cohort.manifest.schemaVersion == ReleaseManifestSchema.v2.rawValue) #expect( cohort.manifest.artifacts.map(\.name) diff --git a/Tests/PrivateHeaderKitRawDumpTests/PrivateHeaderKitRawDumpTests.swift b/Tests/PrivateHeaderKitRawDumpTests/PrivateHeaderKitRawDumpTests.swift index 2bf6716..aed5b65 100644 --- a/Tests/PrivateHeaderKitRawDumpTests/PrivateHeaderKitRawDumpTests.swift +++ b/Tests/PrivateHeaderKitRawDumpTests/PrivateHeaderKitRawDumpTests.swift @@ -753,7 +753,8 @@ struct PrivateHeaderKitRawDumpSharedCacheTests { ) let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(object["schemaVersion"] as? Int == 1) + #expect(object["schemaVersion"] as? Int == 2) + #expect(object["producerVersion"] as? String == PrivateHeaderKitBuildInfo.version) #expect(object["cacheUUID"] as? String == uuid.uuidString) #expect(object["imagePaths"] as? [String] == ["/usr/lib/libobjc.A.dylib"]) } diff --git a/Tests/PrivateHeaderKitTestSupport/TestSupport.swift b/Tests/PrivateHeaderKitTestSupport/TestSupport.swift index ea6f51d..23767c1 100644 --- a/Tests/PrivateHeaderKitTestSupport/TestSupport.swift +++ b/Tests/PrivateHeaderKitTestSupport/TestSupport.swift @@ -8,6 +8,12 @@ public typealias TestCaptureChunksHandler = @Sendable ( CommandStandardOutputConsumer ) async throws -> Void +public typealias TestCaptureHandler = @Sendable ( + [String], + [String: String]?, + URL? +) async throws -> String + public struct RecordedCommand: Equatable, Sendable { public let command: [String] public let env: [String: String]? @@ -28,6 +34,7 @@ public actor RecordingCommandRunner: CommandRunning { private var captureOutputs: [String: String] = [:] private var captureOutputQueues: [String: [String]] = [:] private var captureChunks: [String: [Data]] = [:] + private var captureHandler: TestCaptureHandler? private var captureChunksHandler: TestCaptureChunksHandler? private var simpleHandler: (@Sendable ([String], [String: String]?, URL?) async throws -> Void)? private var streamingHandler: @@ -51,6 +58,10 @@ public actor RecordingCommandRunner: CommandRunning { captureChunksHandler = handler } + public func setCaptureHandler(_ handler: TestCaptureHandler?) { + captureHandler = handler + } + public func setSimpleHandler( _ handler: (@Sendable ([String], [String: String]?, URL?) async throws -> Void)? ) { @@ -81,7 +92,11 @@ public actor RecordingCommandRunner: CommandRunning { env: [String: String]?, cwd: URL? ) async throws -> String { - try captureOutput(command, env: env, cwd: cwd) + if let captureHandler { + captureCommands.append(RecordedCommand(command: command, env: env, cwd: cwd)) + return try await captureHandler(command, env, cwd) + } + return try captureOutput(command, env: env, cwd: cwd) } public func runCaptureChunks( diff --git a/Tests/PrivateHeaderKitToolingTests/ToolingDeterministicTests.swift b/Tests/PrivateHeaderKitToolingTests/ToolingDeterministicTests.swift index 1e29b93..ab625c4 100644 --- a/Tests/PrivateHeaderKitToolingTests/ToolingDeterministicTests.swift +++ b/Tests/PrivateHeaderKitToolingTests/ToolingDeterministicTests.swift @@ -336,6 +336,34 @@ struct SimctlDeterministicTests { #expect(devices.map(\.state) == ["Shutdown", "Booted"]) } + @Test func explicitDeviceIsBorrowedWithoutCreatingOrDeletingIt() async throws { + let runner = RecordingCommandRunner() + let runtime = RuntimeInfo( + platform: .iOS, + version: "27.0", + build: "24A5355q", + identifier: "ios-27", + runtimeRoot: "/runtimes/27" + ) + await runner.setCaptureOutput( + """ + {"devices":{"ios-27":[{"name":"User Device","udid":"USER-001","state":"Booted"}]}} + """, + for: ["xcrun", "simctl", "list", "devices", "-j"] + ) + + let resolved = try await Simctl.resolveDevice( + runtime: runtime, + query: "USER-001", + runner: runner, + environment: [:] + ) + + #expect(resolved.ownership == .borrowed) + #expect(resolved.device.udid == "USER-001") + #expect(await runner.simpleCommandSnapshot().isEmpty) + } + @Test func ensureDeviceBootedSkipsBootedDeviceUnlessForced() async throws { let runner = RecordingCommandRunner() let booted = DeviceInfo(name: "iPhone", udid: "BOOTED", state: "Booted") @@ -352,7 +380,7 @@ struct SimctlDeterministicTests { ]) } - @Test func resolveDeviceCreatesRelistsClonesAndBootsWhenRuntimeHasNoDevices() async throws { + @Test func resolveDeviceCreatesOneDedicatedDeviceAndBootsIt() async throws { let runner = RecordingCommandRunner() let runtime = RuntimeInfo( platform: .iOS, @@ -373,44 +401,195 @@ struct SimctlDeterministicTests { ), ] ) - await runner.setCaptureOutputs( - [ - """ - {"devices":{"ios-27":[]}} - """, - """ - {"devices":{"ios-27":[{"name":"iPhone 17 (27.0)","udid":"BASE-001","state":"Shutdown"}]}} - """, - ], - for: ["xcrun", "simctl", "list", "devices", "-j"] - ) + let createdUDID = "11111111-2222-3333-4444-555555555555" await runner.setCaptureOutput( - "CLONE-001\n", - for: ["xcrun", "simctl", "clone", "BASE-001", "Dumping Device (iOS 27.0)"] + "\(createdUDID)\n", + for: [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] ) - let device = try await Simctl.resolveDevice(runtime: runtime, query: nil, runner: runner, environment: [:]) + let resolved = try await Simctl.resolveDevice( + runtime: runtime, + query: nil, + runner: runner, + environment: [:], + dedicatedDeviceName: "PrivateHeaderKit Dump (iOS 27.0)" + ) - #expect(device.name == "Dumping Device (iOS 27.0)") - #expect(device.udid == "CLONE-001") - #expect(device.state == "Booted") + #expect(resolved.ownership == .runOwned) + #expect(resolved.device.name == "PrivateHeaderKit Dump (iOS 27.0)") + #expect(resolved.device.udid == createdUDID) + #expect(resolved.device.state == "Booted") #expect(await runner.simpleCommandSnapshot().map(\.command) == [ - [ - "xcrun", - "simctl", - "create", - "iPhone 17 (27.0)", - "com.apple.CoreSimulator.SimDeviceType.iPhone-17", - "ios-27", - ], - ["xcrun", "simctl", "boot", "CLONE-001"], - ["xcrun", "simctl", "bootstatus", "CLONE-001", "-b"], + ["xcrun", "simctl", "boot", createdUDID], + ["xcrun", "simctl", "bootstatus", createdUDID, "-b"], ]) let capturedCommands = await runner.captureCommandSnapshot().map(\.command) + #expect(!capturedCommands.contains(["xcrun", "simctl", "list", "devices", "-j"])) #expect(!capturedCommands.contains(["xcrun", "simctl", "list", "devicetypes", "-j"])) } - @Test func createDefaultDeviceFallsBackToRuntimeCompatibleDeviceTypes() async throws { + @Test func bootFailureDeletesTheNewDedicatedDeviceBeforeReturning() async throws { + let runner = RecordingCommandRunner() + let runtime = RuntimeInfo( + platform: .iOS, + version: "27.0", + build: "24A5355q", + identifier: "ios-27", + runtimeRoot: "/runtimes/27", + supportedDeviceTypes: [ + DeviceTypeInfo( + name: "iPhone 17", + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + productFamily: "iPhone" + ), + ] + ) + let createdUDID = "11111111-2222-3333-4444-555555555555" + await runner.setCaptureOutput( + createdUDID + "\n", + for: [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + ) + await runner.setSimpleHandler { command, _, _ in + if command == ["xcrun", "simctl", "boot", createdUDID] { + throw SimctlTestError.bootFailed + } + } + + await #expect(throws: SimctlTestError.self) { + _ = try await Simctl.resolveDevice( + runtime: runtime, + query: nil, + runner: runner, + environment: [:], + dedicatedDeviceName: "PrivateHeaderKit Dump (iOS 27.0)" + ) + } + + #expect(await runner.simpleCommandSnapshot().map(\.command) == [ + ["xcrun", "simctl", "boot", createdUDID], + ["xcrun", "simctl", "delete", createdUDID], + ]) + } + + @Test func malformedCreateOutputDeletesTheExactNamedDeviceBeforeReturning() async throws { + let runner = RecordingCommandRunner() + let runtime = RuntimeInfo( + platform: .iOS, + version: "27.0", + build: "24A5355q", + identifier: "ios-27", + runtimeRoot: "/runtimes/27", + supportedDeviceTypes: [ + DeviceTypeInfo( + name: "iPhone 17", + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + productFamily: "iPhone" + ), + ] + ) + let createdName = "PrivateHeaderKit Dump (iOS 27.0) run-malformed" + let createdUDID = "11111111-2222-3333-4444-555555555555" + let createCommand = [ + "xcrun", "simctl", "create", createdName, + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + await runner.setCaptureOutput("unexpected output\n", for: createCommand) + await runner.setCaptureOutput( + """ + {"devices":{"ios-27":[{"name":"\(createdName)","udid":"\(createdUDID)","state":"Shutdown"}]}} + """, + for: ["xcrun", "simctl", "list", "devices", "-j"] + ) + + await #expect(throws: ToolingError.self) { + _ = try await Simctl.createDedicatedDevice( + runtime: runtime, + name: createdName, + runner: runner, + environment: [:] + ) + } + + #expect(await runner.captureCommandSnapshot().map(\.command) == [ + createCommand, + ["xcrun", "simctl", "list", "devices", "-j"], + ]) + #expect(await runner.simpleCommandSnapshot().map(\.command) == [ + ["xcrun", "simctl", "delete", createdUDID], + ]) + } + + @Test func cancellationDuringCreateReconcilesAndDeletesTheExactNamedDevice() async throws { + let runner = RecordingCommandRunner() + let runtime = RuntimeInfo( + platform: .iOS, + version: "27.0", + build: "24A5355q", + identifier: "ios-27", + runtimeRoot: "/runtimes/27", + supportedDeviceTypes: [ + DeviceTypeInfo( + name: "iPhone 17", + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + productFamily: "iPhone" + ), + ] + ) + let createdName = "PrivateHeaderKit Dump (iOS 27.0) run-cancelled" + let createdUDID = "11111111-2222-3333-4444-555555555555" + let createCommand = [ + "xcrun", "simctl", "create", createdName, + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + let listCommand = ["xcrun", "simctl", "list", "devices", "-j"] + let createStarted = AsyncTestEvent() + await runner.setCaptureHandler { command, _, _ in + if command == createCommand { + await createStarted.signal() + while true { + try Task.checkCancellation() + await Task.yield() + } + } + if command == listCommand { + return """ + {"devices":{"ios-27":[{"name":"\(createdName)","udid":"\(createdUDID)","state":"Shutdown"}]}} + """ + } + throw ToolingError.message("unexpected command: \(command)") + } + + let task = Task { + try await Simctl.resolveDevice( + runtime: runtime, + query: nil, + runner: runner, + environment: [:], + dedicatedDeviceName: createdName + ) + } + await createStarted.wait() + task.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + #expect(await runner.captureCommandSnapshot().map(\.command) == [ + createCommand, + listCommand, + ]) + #expect(await runner.simpleCommandSnapshot().map(\.command) == [ + ["xcrun", "simctl", "delete", createdUDID], + ]) + } + + @Test func createDedicatedDeviceFallsBackToRuntimeCompatibleDeviceTypes() async throws { let runner = RecordingCommandRunner() let runtime = RuntimeInfo( platform: .iOS, @@ -449,22 +628,26 @@ struct SimctlDeterministicTests { """, for: ["xcrun", "simctl", "list", "devicetypes", "-j"] ) + let createCommand = [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + let createdUDID = "11111111-2222-3333-4444-555555555555" + await runner.setCaptureOutput(createdUDID + "\n", for: createCommand) + + let device = try await Simctl.createDedicatedDevice( + runtime: runtime, + name: "PrivateHeaderKit Dump (iOS 27.0)", + runner: runner, + environment: [:] + ) - try await Simctl.createDefaultDevice(runtime: runtime, runner: runner, environment: [:]) - - #expect(await runner.simpleCommandSnapshot().map(\.command) == [ - [ - "xcrun", - "simctl", - "create", - "iPhone 17 (27.0)", - "com.apple.CoreSimulator.SimDeviceType.iPhone-17", - "ios-27", - ], - ]) + #expect(device.udid == createdUDID) + #expect(await runner.captureCommandSnapshot().map(\.command).last == createCommand) + #expect(await runner.simpleCommandSnapshot().isEmpty) } - @Test func createDefaultDeviceFallsBackToNumericRuntimeCompatibleDeviceTypes() async throws { + @Test func createDedicatedDeviceFallsBackToNumericRuntimeCompatibleDeviceTypes() async throws { let runner = RecordingCommandRunner() let runtime = RuntimeInfo( platform: .iOS, @@ -510,22 +693,26 @@ struct SimctlDeterministicTests { """, for: ["xcrun", "simctl", "list", "devicetypes", "-j"] ) + let createCommand = [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", "ios-27", + ] + await runner.setCaptureOutput( + "11111111-2222-3333-4444-555555555555\n", + for: createCommand + ) - try await Simctl.createDefaultDevice(runtime: runtime, runner: runner, environment: [:]) + _ = try await Simctl.createDedicatedDevice( + runtime: runtime, + name: "PrivateHeaderKit Dump (iOS 27.0)", + runner: runner, + environment: [:] + ) - #expect(await runner.simpleCommandSnapshot().map(\.command) == [ - [ - "xcrun", - "simctl", - "create", - "iPhone 17 (27.0)", - "com.apple.CoreSimulator.SimDeviceType.iPhone-17", - "ios-27", - ], - ]) + #expect(await runner.captureCommandSnapshot().map(\.command).last == createCommand) } - @Test func createDefaultDeviceFallsBackToFirstIPhoneWhenCompatibilityMetadataIsAbsent() async throws { + @Test func createDedicatedDeviceFallsBackToFirstIPhoneWhenCompatibilityMetadataIsAbsent() async throws { let runner = RecordingCommandRunner() let runtime = RuntimeInfo( platform: .iOS, @@ -553,22 +740,26 @@ struct SimctlDeterministicTests { """, for: ["xcrun", "simctl", "list", "devicetypes", "-j"] ) + let createCommand = [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (iOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.iPhone-16", "ios-27", + ] + await runner.setCaptureOutput( + "11111111-2222-3333-4444-555555555555\n", + for: createCommand + ) - try await Simctl.createDefaultDevice(runtime: runtime, runner: runner, environment: [:]) + _ = try await Simctl.createDedicatedDevice( + runtime: runtime, + name: "PrivateHeaderKit Dump (iOS 27.0)", + runner: runner, + environment: [:] + ) - #expect(await runner.simpleCommandSnapshot().map(\.command) == [ - [ - "xcrun", - "simctl", - "create", - "iPhone 16 (27.0)", - "com.apple.CoreSimulator.SimDeviceType.iPhone-16", - "ios-27", - ], - ]) + #expect(await runner.captureCommandSnapshot().map(\.command).last == createCommand) } - @Test func createDefaultWatchDevicePrefersAppleWatchFamily() async throws { + @Test func createDedicatedWatchDevicePrefersAppleWatchFamily() async throws { let runner = RecordingCommandRunner() let runtime = RuntimeInfo( platform: .watchOS, @@ -590,22 +781,46 @@ struct SimctlDeterministicTests { ] ) - try await Simctl.createDefaultDevice(runtime: runtime, runner: runner, environment: [:]) + let createCommand = [ + "xcrun", "simctl", "create", "PrivateHeaderKit Dump (watchOS 27.0)", + "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-11-46mm", "watch-27", + ] + await runner.setCaptureOutput( + "11111111-2222-3333-4444-555555555555\n", + for: createCommand + ) - #expect( - Simctl.defaultCloneName(platform: .watchOS, version: "27.0") - == "Dumping Device (watchOS 27.0)" + _ = try await Simctl.createDedicatedDevice( + runtime: runtime, + name: "PrivateHeaderKit Dump (watchOS 27.0)", + runner: runner, + environment: [:] ) - #expect(await runner.simpleCommandSnapshot().map(\.command) == [ - [ - "xcrun", - "simctl", - "create", - "Apple Watch Series 11 (46mm) (27.0)", - "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-11-46mm", - "watch-27", - ], - ]) + + #expect(await runner.captureCommandSnapshot().map(\.command) == [createCommand]) + } +} + +private enum SimctlTestError: Error { + case bootFailed +} + +private actor AsyncTestEvent { + private var didSignal = false + private var waiters: [CheckedContinuation] = [] + + func signal() { + didSignal = true + let continuations = waiters + waiters.removeAll() + continuations.forEach { $0.resume() } + } + + func wait() async { + if didSignal { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } } } From 7ba0ed18da1174bd9d242e0c445ae49913d33d13 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:17:04 +0900 Subject: [PATCH 2/4] Refresh embedded versions when source identity changes --- .../PrivateHeaderKitBuildInfoPlugin.swift | 84 +++++++++++++++++-- .../PrivateHeaderKitBuildInfoTool.swift | 28 ++++++- .../PrivateHeaderKitInstallMain.swift | 4 +- .../SourceInstallSnapshot.swift | 24 ++++-- .../PrivateHeaderKitInstallTests.swift | 3 + 5 files changed, 124 insertions(+), 19 deletions(-) diff --git a/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift index 087422b..17f223b 100644 --- a/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift +++ b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift @@ -18,10 +18,6 @@ struct PrivateHeaderKitBuildInfoPlugin: BuildToolPlugin { ] if let environmentVersion = ProcessInfo.processInfo.environment[Self.environmentKey] { arguments.append(contentsOf: ["--environment-version", environmentVersion]) - } else if let gitVersion = Self.gitDescribe(in: context.package.directoryURL) { - // Keep the Git identity in the build-command signature. Reading Git only inside the - // tool would let SwiftPM reuse a stale generated source after HEAD changes. - arguments.append(contentsOf: ["--environment-version", gitVersion]) } return [ @@ -29,19 +25,89 @@ struct PrivateHeaderKitBuildInfoPlugin: BuildToolPlugin { displayName: "Generate PrivateHeaderKit build info", executable: tool.url, arguments: arguments, + inputFiles: Self.identityInputFiles( + in: context.package.directoryURL + ), outputFiles: [outputFile] ) ] } - private static func gitDescribe(in packageDirectory: URL) -> String? { + private static func identityInputFiles(in packageDirectory: URL) -> [URL] { + var inputs = [ + packageDirectory.appending(path: "Package.swift"), + ] + let resolved = packageDirectory.appending(path: "Package.resolved") + if FileManager.default.fileExists(atPath: resolved.path) { + inputs.append(resolved) + } + for relativeDirectory in ["Plugins", "Sources"] { + let directory = packageDirectory.appending(path: relativeDirectory) + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { continue } + for case let url as URL in enumerator { + guard let values = try? url.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + ]), + values.isRegularFile == true || values.isSymbolicLink == true + else { continue } + inputs.append(url) + } + } + inputs.append(contentsOf: gitReferenceInputs(in: packageDirectory)) + return Dictionary(grouping: inputs, by: \.standardizedFileURL.path) + .compactMap { $0.value.first } + .sorted { $0.path < $1.path } + } + + private static func gitReferenceInputs(in packageDirectory: URL) -> [URL] { + var inputs: [URL] = [] + if let headPath = gitOutput( + ["rev-parse", "--git-path", "HEAD"], + in: packageDirectory + ) { + inputs.append(gitURL(path: headPath, packageDirectory: packageDirectory)) + } + let headLogURL = gitOutput( + ["rev-parse", "--git-path", "logs/HEAD"], + in: packageDirectory + ).map { gitURL(path: $0, packageDirectory: packageDirectory) } + if let headLogURL, + FileManager.default.fileExists(atPath: headLogURL.path) + { + inputs.append(headLogURL) + } else if let reference = gitOutput( + ["symbolic-ref", "-q", "HEAD"], + in: packageDirectory + ), + let referencePath = gitOutput( + ["rev-parse", "--git-path", reference], + in: packageDirectory + ) + { + inputs.append( + gitURL(path: referencePath, packageDirectory: packageDirectory) + ) + } + return inputs.filter { FileManager.default.fileExists(atPath: $0.path) } + } + + private static func gitURL(path: String, packageDirectory: URL) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return packageDirectory.appending(path: path) + } + + private static func gitOutput(_ arguments: [String], in packageDirectory: URL) -> String? { let process = Process() let outputPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = [ - "-C", packageDirectory.path, - "describe", "--tags", "--always", "--dirty", - ] + process.arguments = ["-C", packageDirectory.path] + arguments process.standardOutput = outputPipe process.standardError = Pipe() do { diff --git a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift index bf5ede4..65271c5 100644 --- a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift +++ b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift @@ -132,13 +132,33 @@ package enum BuildVersionResolver { } private static func defaultGitDescribe(in packageDirectory: URL) throws -> String? { + guard let description = try gitOutput( + ["describe", "--tags", "--always"], + in: packageDirectory + ), + let status = try gitOutput( + [ + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + "Package.swift", "Package.resolved", "Plugins", "Sources", + ], + in: packageDirectory + ) + else { + return nil + } + let version = description.trimmingCharacters(in: .whitespacesAndNewlines) + guard !version.isEmpty else { return nil } + return status.isEmpty ? version : "\(version)-dirty" + } + + private static func gitOutput( + _ arguments: [String], + in packageDirectory: URL + ) throws -> String? { let process = Process() let outputPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = [ - "-C", packageDirectory.path, - "describe", "--tags", "--always", "--dirty", - ] + process.arguments = ["-C", packageDirectory.path] + arguments process.standardOutput = outputPipe process.standardError = Pipe() try process.run() diff --git a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift index f6d2c56..7faa2bc 100644 --- a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift +++ b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift @@ -357,7 +357,9 @@ func buildSourceCohort( fileManager: fileManager ) let buildEnvironment = [ - "PRIVATEHEADERKIT_BUILD_VERSION": sourceBeforeBuild.effectiveVersion, + "PRIVATEHEADERKIT_BUILD_VERSION": sourceBeforeBuild.isDirty + ? "\(sourceBeforeBuild.effectiveVersion)-dirty" + : sourceBeforeBuild.effectiveVersion, "PRIVATEHEADERKIT_BUILD_COMMIT": sourceBeforeBuild.effectiveCommit, ] try await buildProducts( diff --git a/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift b/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift index 3692806..9e2c361 100644 --- a/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift +++ b/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift @@ -13,10 +13,16 @@ struct SourceSnapshot: Equatable, Sendable { let head: String let effectiveCommit: String let dirtyInputFingerprint: String + let isDirty: Bool let releaseTags: [String] let effectiveVersion: String } +private struct SourceDirtyInputIdentity: Sendable { + let fingerprint: String + let isDirty: Bool +} + private struct UntrackedSourceRecord: Codable, Sendable { let path: String let kind: String @@ -53,7 +59,7 @@ func captureSourceSnapshot( commit: effectiveCommit, releaseTags: releaseTags ) - let dirtyInputFingerprint = try await sourceDirtyInputFingerprint( + let dirtyInputs = try await sourceDirtyInputFingerprint( repoRoot: repoRoot, runner: runner, fileManager: fileManager @@ -61,7 +67,8 @@ func captureSourceSnapshot( return SourceSnapshot( head: head, effectiveCommit: effectiveCommit, - dirtyInputFingerprint: dirtyInputFingerprint, + dirtyInputFingerprint: dirtyInputs.fingerprint, + isDirty: dirtyInputs.isDirty, releaseTags: releaseTags, effectiveVersion: effectiveVersion ) @@ -138,7 +145,7 @@ private func sourceDirtyInputFingerprint( repoRoot: URL, runner: CommandRunning, fileManager: FileManager -) async throws -> String { +) async throws -> SourceDirtyInputIdentity { let canonicalHasher = SourceFingerprintCanonicalHasher() try await runner.runCaptureChunks( ["git", "diff", "--no-ext-diff", "--binary", "HEAD", "--"], @@ -286,9 +293,13 @@ private actor SourceFingerprintCanonicalHasher { private var pendingUntrackedPathBytes = Data() private var untrackedPaths = CancellationAwareStringMergeSorter() private var untrackedRecordCount = 0 + private var hasTrackedDiff = false func consumeTrackedDiff(_ chunk: Data) throws { precondition(phase == .trackedDiff) + if !chunk.isEmpty { + hasTrackedDiff = true + } try updateHash(with: chunk) } @@ -345,13 +356,16 @@ private actor SourceFingerprintCanonicalHasher { untrackedRecordCount += 1 } - func finalize() throws -> String { + func finalize() throws -> SourceDirtyInputIdentity { precondition(phase == .untrackedRecords) try updateHash(with: Data("]".utf8)) try Task.checkCancellation() phase = .finalized #if canImport(CryptoKit) - return hasher.finalize().map { String(format: "%02x", $0) }.joined() + return SourceDirtyInputIdentity( + fingerprint: hasher.finalize().map { String(format: "%02x", $0) }.joined(), + isDirty: hasTrackedDiff || untrackedRecordCount > 0 + ) #else throw InstallError.message( "source input fingerprinting is unavailable on this platform" diff --git a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift index 3959226..c6b8bfa 100644 --- a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift +++ b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift @@ -2307,6 +2307,8 @@ struct SourceBuildResolutionTests { #expect(first.head == second.head) #expect(first.dirtyInputFingerprint != second.dirtyInputFingerprint) + #expect(first.isDirty) + #expect(second.isDirty) #expect(first.releaseTags == ["v1.0.0"]) #expect(second.releaseTags == ["v1.0.1"]) #expect(first.effectiveVersion == "v1.0.0") @@ -2391,6 +2393,7 @@ struct SourceBuildResolutionTests { .joined() #expect(snapshot.dirtyInputFingerprint == expectedFingerprint) + #expect(snapshot.isDirty) #expect(snapshot.releaseTags == ["v1.2.3"]) #expect(snapshot.effectiveVersion == "v1.2.3") } From abfb634ab82becccdf5b8547dec421e638fe3be6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:27:32 +0900 Subject: [PATCH 3/4] Fingerprint dirty producer builds --- .../PrivateHeaderKitBuildInfoPlugin.swift | 9 +- .../PrivateHeaderKitBuildInfoTool.swift | 129 ++++++++++++++++-- .../PrivateHeaderKitInstallMain.swift | 4 +- .../SourceInstallSnapshot.swift | 6 + .../PrivateHeaderKitBuildInfoToolTests.swift | 41 ++++++ .../PrivateHeaderKitInstallTests.swift | 8 ++ 6 files changed, 178 insertions(+), 19 deletions(-) diff --git a/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift index 17f223b..01a205d 100644 --- a/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift +++ b/Plugins/PrivateHeaderKitBuildInfoPlugin/PrivateHeaderKitBuildInfoPlugin.swift @@ -109,18 +109,17 @@ struct PrivateHeaderKitBuildInfoPlugin: BuildToolPlugin { process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = ["-C", packageDirectory.path] + arguments process.standardOutput = outputPipe - process.standardError = Pipe() + process.standardError = FileHandle.nullDevice do { try process.run() } catch { return nil } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } - let value = String( - decoding: outputPipe.fileHandleForReading.readDataToEndOfFile(), - as: UTF8.self - ).trimmingCharacters(in: .whitespacesAndNewlines) + let value = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } diff --git a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift index 65271c5..0121655 100644 --- a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift +++ b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit @main struct PrivateHeaderKitBuildInfoTool { @@ -83,6 +84,18 @@ private struct Options { } package enum BuildVersionResolver { + package struct DirtyInputRecord: Hashable, Sendable { + package let path: String + package let kind: String + package let contentDigest: Data + + package init(path: String, kind: String, contentDigest: Data) { + self.path = path + self.kind = kind + self.contentDigest = contentDigest + } + } + package static func resolve( environmentVersion: String?, packageDirectory: URL, @@ -132,13 +145,20 @@ package enum BuildVersionResolver { } private static func defaultGitDescribe(in packageDirectory: URL) throws -> String? { - guard let description = try gitOutput( + guard let description = try gitOutputData( ["describe", "--tags", "--always"], in: packageDirectory ), - let status = try gitOutput( + let trackedDiff = try gitOutputData( [ - "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + "diff", "--no-ext-diff", "--no-textconv", "--binary", "HEAD", "--", + "Package.swift", "Package.resolved", "Plugins", "Sources", + ], + in: packageDirectory + ), + let untrackedPaths = try gitOutputData( + [ + "ls-files", "--others", "--exclude-standard", "-z", "--", "Package.swift", "Package.resolved", "Plugins", "Sources", ], in: packageDirectory @@ -146,26 +166,113 @@ package enum BuildVersionResolver { else { return nil } - let version = description.trimmingCharacters(in: .whitespacesAndNewlines) + let version = String(decoding: description, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) guard !version.isEmpty else { return nil } - return status.isEmpty ? version : "\(version)-dirty" + let records = try untrackedInputRecords( + pathsData: untrackedPaths, + packageDirectory: packageDirectory + ) + guard let fingerprint = dirtyFingerprint( + trackedDiff: trackedDiff, + untrackedRecords: records + ) else { + return version + } + return "\(version)-dirty.\(fingerprint)" + } + + package static func dirtyFingerprint( + trackedDiff: Data, + untrackedRecords: [DirtyInputRecord] + ) -> String? { + guard !trackedDiff.isEmpty || !untrackedRecords.isEmpty else { return nil } + var hasher = SHA256() + appendCanonical(Data("privateheaderkit-dirty-input-v1".utf8), to: &hasher) + appendCanonical(trackedDiff, to: &hasher) + for record in untrackedRecords.sorted(by: { lhs, rhs in + lhs.path.utf8.lexicographicallyPrecedes(rhs.path.utf8) + }) { + appendCanonical(Data(record.path.utf8), to: &hasher) + appendCanonical(Data(record.kind.utf8), to: &hasher) + appendCanonical(record.contentDigest, to: &hasher) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() } - private static func gitOutput( + private static func untrackedInputRecords( + pathsData: Data, + packageDirectory: URL + ) throws -> [DirtyInputRecord] { + try pathsData.split(separator: 0).map { bytes in + let path = String(decoding: bytes, as: UTF8.self) + guard !path.hasPrefix("/"), + !path.split(separator: "/").contains("..") + else { + throw BuildInfoToolError.message( + "Git reported an unsafe untracked source path: \(path)" + ) + } + let url = packageDirectory.appendingPathComponent(path, isDirectory: false) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + switch attributes[.type] as? FileAttributeType { + case .typeRegular: + return DirtyInputRecord( + path: path, + kind: "regular", + contentDigest: try fileDigest(at: url) + ) + case .typeSymbolicLink: + let destination = try FileManager.default.destinationOfSymbolicLink( + atPath: url.path + ) + return DirtyInputRecord( + path: path, + kind: "symlink", + contentDigest: Data(SHA256.hash(data: Data(destination.utf8))) + ) + default: + throw BuildInfoToolError.message( + "untracked source input is not a regular file or symbolic link: \(path)" + ) + } + } + } + + private static func fileDigest(at url: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let data = try handle.read(upToCount: 1024 * 1024), !data.isEmpty { + hasher.update(data: data) + } + return Data(hasher.finalize()) + } + + private static func appendCanonical(_ data: Data, to hasher: inout SHA256) { + var length = Data() + let count = UInt64(data.count) + for shift in stride(from: 56, through: 0, by: -8) { + length.append(UInt8(truncatingIfNeeded: count >> shift)) + } + hasher.update(data: length) + hasher.update(data: data) + } + + private static func gitOutputData( _ arguments: [String], in packageDirectory: URL - ) throws -> String? { + ) throws -> Data? { let process = Process() let outputPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = ["-C", packageDirectory.path] + arguments process.standardOutput = outputPipe - process.standardError = Pipe() + process.standardError = FileHandle.nullDevice try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } let data = outputPipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) + process.waitUntilExit() + return process.terminationStatus == 0 ? data : nil } private static func escapedStringLiteral(_ value: String) -> String { diff --git a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift index 7faa2bc..b17b8fa 100644 --- a/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift +++ b/Sources/PrivateHeaderKitInstall/PrivateHeaderKitInstallMain.swift @@ -357,9 +357,7 @@ func buildSourceCohort( fileManager: fileManager ) let buildEnvironment = [ - "PRIVATEHEADERKIT_BUILD_VERSION": sourceBeforeBuild.isDirty - ? "\(sourceBeforeBuild.effectiveVersion)-dirty" - : sourceBeforeBuild.effectiveVersion, + "PRIVATEHEADERKIT_BUILD_VERSION": sourceBeforeBuild.producerVersion, "PRIVATEHEADERKIT_BUILD_COMMIT": sourceBeforeBuild.effectiveCommit, ] try await buildProducts( diff --git a/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift b/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift index 9e2c361..674421a 100644 --- a/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift +++ b/Sources/PrivateHeaderKitInstall/SourceInstallSnapshot.swift @@ -16,6 +16,12 @@ struct SourceSnapshot: Equatable, Sendable { let isDirty: Bool let releaseTags: [String] let effectiveVersion: String + + var producerVersion: String { + isDirty + ? "\(effectiveVersion)-dirty.\(dirtyInputFingerprint)" + : effectiveVersion + } } private struct SourceDirtyInputIdentity: Sendable { diff --git a/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift b/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift index de305ec..1d380c4 100644 --- a/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift +++ b/Tests/PrivateHeaderKitBuildInfoToolTests/PrivateHeaderKitBuildInfoToolTests.swift @@ -40,4 +40,45 @@ struct PrivateHeaderKitBuildInfoToolTests { #expect(source.contains(#"package static let version = "v1.2.3-\"quoted\"\\path""#)) } + + @Test func dirtyFingerprintTracksChangedAndUntrackedSourceContents() { + #expect( + BuildVersionResolver.dirtyFingerprint( + trackedDiff: Data(), + untrackedRecords: [] + ) == nil + ) + + let firstTracked = BuildVersionResolver.dirtyFingerprint( + trackedDiff: Data("first diff".utf8), + untrackedRecords: [] + ) + let secondTracked = BuildVersionResolver.dirtyFingerprint( + trackedDiff: Data("second diff".utf8), + untrackedRecords: [] + ) + #expect(firstTracked != secondTracked) + + let firstUntracked = BuildVersionResolver.dirtyFingerprint( + trackedDiff: Data(), + untrackedRecords: [ + .init( + path: "Sources/New.swift", + kind: "regular", + contentDigest: Data("first contents".utf8) + ), + ] + ) + let secondUntracked = BuildVersionResolver.dirtyFingerprint( + trackedDiff: Data(), + untrackedRecords: [ + .init( + path: "Sources/New.swift", + kind: "regular", + contentDigest: Data("second contents".utf8) + ), + ] + ) + #expect(firstUntracked != secondUntracked) + } } diff --git a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift index c6b8bfa..11c5908 100644 --- a/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift +++ b/Tests/PrivateHeaderKitInstallTests/PrivateHeaderKitInstallTests.swift @@ -2309,6 +2309,14 @@ struct SourceBuildResolutionTests { #expect(first.dirtyInputFingerprint != second.dirtyInputFingerprint) #expect(first.isDirty) #expect(second.isDirty) + #expect( + first.producerVersion + == "v1.0.0-dirty.\(first.dirtyInputFingerprint)" + ) + #expect( + second.producerVersion + == "v1.0.1-dirty.\(second.dirtyInputFingerprint)" + ) #expect(first.releaseTags == ["v1.0.0"]) #expect(second.releaseTags == ["v1.0.1"]) #expect(first.effectiveVersion == "v1.0.0") From ed7e46d77d2e898916f4e0a517f543394626f4d9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:35:45 +0900 Subject: [PATCH 4/4] Use HEAD identity for development builds --- .../PrivateHeaderKitBuildInfoTool.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift index 0121655..6cc0faa 100644 --- a/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift +++ b/Sources/PrivateHeaderKitBuildInfoTool/PrivateHeaderKitBuildInfoTool.swift @@ -145,8 +145,8 @@ package enum BuildVersionResolver { } private static func defaultGitDescribe(in packageDirectory: URL) throws -> String? { - guard let description = try gitOutputData( - ["describe", "--tags", "--always"], + guard let commitData = try gitOutputData( + ["rev-parse", "HEAD"], in: packageDirectory ), let trackedDiff = try gitOutputData( @@ -166,7 +166,7 @@ package enum BuildVersionResolver { else { return nil } - let version = String(decoding: description, as: UTF8.self) + let version = String(decoding: commitData, as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) guard !version.isEmpty else { return nil } let records = try untrackedInputRecords(