fix: prevent race condition when installing multiple Xcode versions concurrently

Each XIP is now extracted into a version-specific directory (e.g. Xcode-16.0-extract/)
instead of sharing the same parent directory, eliminating the race where one version's
extracted Xcode.app could be moved to another version's destination path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Xerol Wong 2026-03-06 20:23:59 +09:00
parent 1a0d3353b9
commit 038c0e109f
4 changed files with 26 additions and 13 deletions

View file

@ -250,8 +250,17 @@ extension AppState {
func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher<URL, Swift.Error> { func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher<URL, Swift.Error> {
self.setInstallationStep(of: availableXcode.version, to: .unarchiving) self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
return unxipOrUnxipExperiment(source) // Use a version-specific extraction directory to prevent conflicts when
// multiple Xcode versions are installed concurrently. Without this, all
// XIP files extract to the same parent directory as "Xcode.app", causing
// a race condition where one versions extracted app gets renamed to
// another versions destination path.
let extractionDirectory = source.deletingLastPathComponent()
.appendingPathComponent("Xcode-\(availableXcode.version)-extract")
try? Current.files.createDirectory(at: extractionDirectory, withIntermediateDirectories: true, attributes: nil)
return unxipOrUnxipExperiment(source, extractionDirectory: extractionDirectory)
.catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in .catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in
if let executionError = error as? ProcessExecutionError { if let executionError = error as? ProcessExecutionError {
if executionError.standardError.contains("damaged and cant be expanded") { if executionError.standardError.contains("damaged and cant be expanded") {
@ -269,8 +278,8 @@ extension AppState {
.tryMap { output -> URL in .tryMap { output -> URL in
self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path))
let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") let xcodeURL = extractionDirectory.appendingPathComponent("Xcode.app")
let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") let xcodeBetaURL = extractionDirectory.appendingPathComponent("Xcode-beta.app")
if Current.files.fileExists(atPath: xcodeURL.path) { if Current.files.fileExists(atPath: xcodeURL.path) {
try Current.files.moveItem(at: xcodeURL, to: destination) try Current.files.moveItem(at: xcodeURL, to: destination)
} }
@ -278,6 +287,7 @@ extension AppState {
try Current.files.moveItem(at: xcodeBetaURL, to: destination) try Current.files.moveItem(at: xcodeBetaURL, to: destination)
} }
try? Current.files.removeItem(at: extractionDirectory)
return destination return destination
} }
.handleEvents(receiveCancel: { .handleEvents(receiveCancel: {
@ -287,17 +297,20 @@ extension AppState {
if Current.files.fileExists(atPath: destination.path) { if Current.files.fileExists(atPath: destination.path) {
try? Current.files.removeItem(destination) try? Current.files.removeItem(destination)
} }
if Current.files.fileExists(atPath: extractionDirectory.path) {
try? Current.files.removeItem(extractionDirectory)
}
}) })
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher<ProcessOutput, Error> { func unxipOrUnxipExperiment(_ source: URL, extractionDirectory: URL) -> AnyPublisher<ProcessOutput, Error> {
if unxipExperiment { if unxipExperiment {
// All hard work done by https://github.com/saagarjha/unxip // All hard work done by https://github.com/saagarjha/unxip
// Compiled to binary with `swiftc -parse-as-library -O unxip.swift` // Compiled to binary with `swiftc -parse-as-library -O unxip.swift`
return Current.shell.unxipExperiment(source) return Current.shell.unxipExperiment(source, extractionDirectory)
} else { } else {
return Current.shell.unxip(source) return Current.shell.unxip(source, extractionDirectory)
} }
} }

View file

@ -25,7 +25,7 @@ public struct Environment {
public var Current = Environment() public var Current = Environment()
public struct Shell { public struct Shell {
public var unxip: (URL) -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } public var unxip: (URL, URL) -> AnyPublisher<ProcessOutput, Error> = { xipURL, workingDirectory in Process.run(Path.root.usr.bin.xip, workingDirectory: workingDirectory, "--expand", "\(xipURL.path)") }
public var spctlAssess: (URL) -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var spctlAssess: (URL) -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") }
public var codesignVerify: (URL) -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } public var codesignVerify: (URL) -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") }
public var buildVersion: () -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } public var buildVersion: () -> AnyPublisher<ProcessOutput, Error> = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") }
@ -191,9 +191,9 @@ public struct Shell {
} }
public var unxipExperiment: (URL) -> AnyPublisher<ProcessOutput, Error> = { url in public var unxipExperiment: (URL, URL) -> AnyPublisher<ProcessOutput, Error> = { xipURL, workingDirectory in
let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)!
return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) return Process.run(unxipPath.url, workingDirectory: workingDirectory, ["\(xipURL.path)"])
} }
public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream<Progress, Error> = { platform, version, architecture in public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream<Progress, Error> = { platform, version, architecture in

View file

@ -291,7 +291,7 @@ class AppStateTests: XCTestCase {
} }
func test_Install_NotEnoughFreeSpace() throws { func test_Install_NotEnoughFreeSpace() throws {
Current.shell.unxip = { _ in Current.shell.unxip = { _, _ in
Fail(error: ProcessExecutionError( Fail(error: ProcessExecutionError(
process: Process(), process: Process(),
standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)", standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)",

View file

@ -18,7 +18,7 @@ extension Shell {
static var processOutputMock: ProcessOutput = (0, "", "") static var processOutputMock: ProcessOutput = (0, "", "")
static var mock = Shell( static var mock = Shell(
unxip: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, unxip: { _, _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() },
spctlAssess: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, spctlAssess: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() },
codesignVerify: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, codesignVerify: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() },
buildVersion: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, buildVersion: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() },