From 5f7a547e97ce110537a7a73b3788eec7a58d2136 Mon Sep 17 00:00:00 2001 From: Mark Statkus Date: Mon, 28 Sep 2026 08:58:20 -0700 Subject: [PATCH] Include toolchain search paths in the build description signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Metal Toolchain component is a cryptex mounted under a new random directory name each time (e.g. after a reboot). SwiftPM passes that mount point to the build service as EXTERNAL_TOOLCHAINS_DIR, and tool paths resolved from it (TOOLCHAIN_DIR, the `metal` compiler) are baked into the build description. Nothing in the build description signature captured where toolchains were found, so after a remount the cached description was reused and the build failed with "unable to spawn process '/…/metal' (No such file or directory)". Add Core.toolchainSearchPathsSignature (the resolved toolchain search paths, including EXTERNAL_TOOLCHAINS_DIR entries) and include it in BuildDescriptionSignatureComponents, mirroring sdkInputsSignature (#1792), so a moved toolchain forces a fresh build description. Fixes #1798 Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/SWBCore/Core.swift | 11 +++++ .../BuildDescriptionSignature.swift | 12 +++++ Tests/SWBCoreTests/CoreTests.swift | 46 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/Sources/SWBCore/Core.swift b/Sources/SWBCore/Core.swift index 60097ca70..5747825c8 100644 --- a/Sources/SWBCore/Core.swift +++ b/Sources/SWBCore/Core.swift @@ -395,6 +395,17 @@ public final class Core: Sendable { FilesSignature(sdkRegistry.inputSignaturePaths) } + /// The resolved toolchain search paths, in order. + /// + /// These include `EXTERNAL_TOOLCHAINS_DIR` entries such as the Metal Toolchain component's cryptex mount point + /// (`…/com.apple.MobileAsset.MetalToolchain-v.`), whose name changes each time it is mounted — + /// for example after a reboot. Tool paths resolved from these directories are baked into build descriptions, + /// so a moved directory must invalidate a cached build description rather than reuse one that points at a + /// toolchain that no longer exists. + public var toolchainSearchPathsSignature: [Path] { + toolchainPaths.map(\.path) + } + /// The toolchain registry. let _toolchainRegistry = UnsafeDelayedInitializationSendableWrapper() public var toolchainRegistry: ToolchainRegistry { diff --git a/Sources/SWBTaskExecution/BuildDescriptionSignature.swift b/Sources/SWBTaskExecution/BuildDescriptionSignature.swift index 48e67bfa3..67d35d9f6 100644 --- a/Sources/SWBTaskExecution/BuildDescriptionSignature.swift +++ b/Sources/SWBTaskExecution/BuildDescriptionSignature.swift @@ -65,6 +65,14 @@ package struct BuildDescriptionSignatureComponents: Codable, Hashable, Sendable /// the version — so an updated SDK forces a fresh build description rather than reusing the previous one from disk. let sdkInputsSignature: FilesSignature + /// The resolved toolchain search paths. + /// + /// External toolchains can move between builds without any other input changing: the Metal Toolchain component + /// is mounted under a new random directory name each time (e.g. after a reboot). Tool paths inside the build + /// description point into that directory, so a moved toolchain forces a fresh build description rather than + /// reusing one that references a path that no longer exists. + let toolchainSearchPaths: [Path] + fileprivate init(_ request: BuildPlanRequest) { workspaceSignature = request.workspaceContext.workspace.signature buildRequestParameters = request.buildRequest.parameters @@ -103,6 +111,10 @@ package struct BuildDescriptionSignatureComponents: Codable, Hashable, Sendable // Hash the SDK metadata files directly, so in-place SDK edits — including version bumps and SDKs updated // independently of Xcode — force a fresh build description instead of reusing a stale one from disk. sdkInputsSignature = request.workspaceContext.core.sdkInputsSignature + + // Include where toolchains were found, so a toolchain that moved (e.g. the Metal Toolchain's cryptex mount, + // renamed on every mount) forces a fresh build description instead of reusing one with stale tool paths. + toolchainSearchPaths = request.workspaceContext.core.toolchainSearchPathsSignature } } diff --git a/Tests/SWBCoreTests/CoreTests.swift b/Tests/SWBCoreTests/CoreTests.swift index e5f092849..bd8d36ca6 100644 --- a/Tests/SWBCoreTests/CoreTests.swift +++ b/Tests/SWBCoreTests/CoreTests.swift @@ -384,6 +384,52 @@ import SWBServiceCore } } + /// The Metal Toolchain component is mounted under a new random directory name each time (e.g. after a reboot). + /// The same toolchain at a new mount point must change the signature, so a cached build description whose tool + /// paths point into the old mount point is not reused. https://github.com/swiftlang/swift-build/issues/1798 + @Test(.skipIfEnvironmentVariableSet(key: .externalToolchainsDir)) + func toolchainSearchPathsSignatureTracksExternalToolchainMount() async throws { + try await withTemporaryDirectory { tmpDir in + try localFS.createDirectory(tmpDir.join("Toolchains")) + let mountA = tmpDir.join("mnt/com.apple.MobileAsset.MetalToolchain-v1.0.aaaaaa").str + let mountB = tmpDir.join("mnt/com.apple.MobileAsset.MetalToolchain-v1.0.bbbbbb").str + + let first = try #require(await makeCore(toolchainPath: tmpDir, environmentOverrides: ["EXTERNAL_TOOLCHAINS_DIR": mountA])) + let same = try #require(await makeCore(toolchainPath: tmpDir, environmentOverrides: ["EXTERNAL_TOOLCHAINS_DIR": mountA])) + let remounted = try #require(await makeCore(toolchainPath: tmpDir, environmentOverrides: ["EXTERNAL_TOOLCHAINS_DIR": mountB])) + + #expect(first.toolchainSearchPathsSignature == same.toolchainSearchPathsSignature) + #expect(first.toolchainSearchPathsSignature != remounted.toolchainSearchPathsSignature) + #expect(remounted.toolchainSearchPathsSignature.contains(Path(mountB))) + } + } + + private func makeCore(toolchainPath: Path, environmentOverrides: [String: String]) async -> Core? { + let delegate = Delegate() + let pluginManager = await MutablePluginManager(pluginLoadingFilter: { _ in true }) + await pluginManager.registerExtensionPoint(DeveloperDirectoryExtensionPoint()) + await pluginManager.registerExtensionPoint(SpecificationsExtensionPoint()) + await pluginManager.registerExtensionPoint(ToolchainRegistryExtensionPoint()) + await pluginManager.register(BuiltinSpecsExtension(), type: SpecificationsExtensionPoint.self) + struct MockDeveloperDirectoryExtensionPoint: DeveloperDirectoryExtension { + let toolchainPath: Path + func fallbackDeveloperDirectory(hostOperatingSystem: OperatingSystem) async throws -> Core.DeveloperPath? { + .swiftToolchain(toolchainPath, xcodeDeveloperPath: nil) + } + } + struct MockToolchainExtension: ToolchainRegistryExtension { + func additionalToolchains(context: any ToolchainRegistryExtensionAdditionalToolchainsContext) async throws -> [Toolchain] { + guard context.toolchainRegistry.lookup(ToolchainRegistry.defaultToolchainIdentifier) == nil else { + return [] + } + return [Toolchain(identifier: ToolchainRegistry.defaultToolchainIdentifier, displayName: "Mock", version: Version(), aliases: ["default"], path: .root, frameworkPaths: [], libraryPaths: [], defaultSettings: [:], overrideSettings: [:], defaultSettingsWhenPrimary: [:], executableSearchPaths: [], testingLibraryPlatformNames: [], fs: context.fs)] + } + } + await pluginManager.register(MockDeveloperDirectoryExtensionPoint(toolchainPath: toolchainPath), type: DeveloperDirectoryExtensionPoint.self) + await pluginManager.register(MockToolchainExtension(), type: ToolchainRegistryExtensionPoint.self) + return await Core.getInitializedCore(delegate, pluginManager: pluginManager, inferiorProductsPath: Path.root.join("invalid"), environment: environmentOverrides, buildServiceModTime: Date(), connectionMode: .inProcess) + } + func toolchainPathsCount() async throws -> Int { try await withTemporaryDirectory { tmpDir in try localFS.createDirectory(tmpDir.join("Toolchains"))