From 031c78fc13dbdba4abfc46eb93f0a6238bcb0b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 19:03:23 +0100 Subject: [PATCH 1/4] fix(apple): fall back to the default Xcode past a dangling selection link xcode-select -p and xcrun skip a /var/db/xcode_select_link whose target was deleted and use /Applications/Xcode.app. #3995 replaced the xcrun probe with a file reader that failed with apple_toolchain_unreadable instead, which blocked iOS input on hosts with a stale link from an uninstalled Xcode. Any other unreadable link still fails. Fixes #4145 --- .../src/__tests__/apple-toolchain-fixtures.ts | 20 +++++++--- .../runner-toolchain-identity.test.ts | 40 ++++++++++++++----- .../src/runner-toolchain-identity.ts | 20 ++++++---- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts index 90569f7703..7f4a9de4a5 100644 --- a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts +++ b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts @@ -2,6 +2,8 @@ import { beforeEach, vi } from 'vitest'; import fs from 'node:fs'; import { XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; +const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; + export { STUBBED_APPLE_TOOLCHAIN, stubbedAppleDeveloperDir, @@ -9,14 +11,17 @@ export { } from '@agent-device/test-kit'; /** - * The host's default selection link as the toolchain identity sees it. Undefined keeps the link - * unreadable; a case that owns a link points it at a path it created. + * The host's selection link and default Xcode as the toolchain identity sees them. Undefined keeps + * a path absent; a case that owns one points it at a path it created. */ -export const appleSelectionLink: { target: string | undefined } = { target: undefined }; +export const appleSelectionLink: { + target: string | undefined; + defaultDeveloperDir: string | undefined; +} = { target: undefined, defaultDeveloperDir: undefined }; /** - * Hides the selection link of the machine running the suite, so a case decides the host's selection - * rather than inheriting it. + * Hides the selection link and default Xcode of the machine running the suite, so a case decides the + * host's selection rather than inheriting it. */ export function stubAppleSelectionLink(): void { const realRealpathSync = fs.realpathSync; @@ -25,12 +30,17 @@ export function stubAppleSelectionLink(): void { Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); beforeEach(() => { appleSelectionLink.target = undefined; + appleSelectionLink.defaultDeveloperDir = undefined; vi.spyOn(fs, 'readlinkSync').mockImplementation(((target: fs.PathLike) => { if (String(target) !== XCODE_SELECT_LINK_PATH) return realReadlinkSync(target); if (appleSelectionLink.target === undefined) throw absent(); return appleSelectionLink.target; }) as typeof fs.readlinkSync); vi.spyOn(fs, 'realpathSync').mockImplementation((target: fs.PathLike) => { + if (String(target) === DEFAULT_DEVELOPER_DIR) { + if (appleSelectionLink.defaultDeveloperDir === undefined) throw absent(); + return realRealpathSync(appleSelectionLink.defaultDeveloperDir); + } if (String(target) !== XCODE_SELECT_LINK_PATH) return realRealpathSync(target); if (appleSelectionLink.target === undefined) throw absent(); return realRealpathSync(appleSelectionLink.target); diff --git a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts index df97a5a23f..f8656c3446 100644 --- a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts +++ b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts @@ -65,17 +65,39 @@ test('without DEVELOPER_DIR the host selection link names the Xcode, and a moved assert.deepEqual(readFingerprint(), FINGERPRINT_B); }); -test('a selection link whose Xcode is gone fails instead of reading another Xcode', () => { +test('a selection link whose Xcode is gone falls back to the default Xcode, as xcode-select -p does', () => { + // #4145: an uninstalled Xcode leaves the link dangling; Apple's tools then use the default Xcode. appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); appleSelectionLink.target = path.join(mkdtempForTestSync('deleted-xcode-'), 'Xcode.app'); - assert.throws( - readFingerprint, - (error: unknown) => - error instanceof AppError && - error.details?.reason === 'apple_toolchain_unreadable' && - error.details?.file === XCODE_SELECT_LINK_PATH && - error.details?.detail === 'dangling selection link', - ); + appleSelectionLink.defaultDeveloperDir = xcodeB; + assert.deepEqual(readFingerprint(), FINGERPRINT_B); + assert.equal(readRunnerXcodeVersion(), '27.0'); +}); + +test('without DEVELOPER_DIR or a selection link the default Xcode names the fingerprint', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.defaultDeveloperDir = xcodeB; + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a selection link whose Xcode this process cannot read fails instead of reading the default Xcode', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + const locked = mkdtempForTestSync('locked-xcode-'); + appleSelectionLink.target = path.join(locked, 'Xcode.app'); + appleSelectionLink.defaultDeveloperDir = xcodeB; + fs.chmodSync(locked, 0o000); + try { + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === XCODE_SELECT_LINK_PATH && + error.details?.detail === 'EACCES', + ); + } finally { + fs.chmodSync(locked, 0o700); + } }); test('a selection link this process cannot read fails instead of reading the default Xcode', () => { diff --git a/packages/apple-runner/src/runner-toolchain-identity.ts b/packages/apple-runner/src/runner-toolchain-identity.ts index 3aad2fb18a..284376fabc 100644 --- a/packages/apple-runner/src/runner-toolchain-identity.ts +++ b/packages/apple-runner/src/runner-toolchain-identity.ts @@ -113,10 +113,10 @@ export function readRunnerXcodeVersion(): string | undefined { /** * The developer dir `xcrun` uses, resolved to its real path: the client's `DEVELOPER_DIR`, else the - * host's selection link, else the default Xcode when no link exists. A tool like xcodes swaps the - * Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. Like - * `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`, and a link whose target is - * gone is a broken selection rather than a reason to read another Xcode. + * host's selection link, else the default Xcode. This order must match `xcode-select -p` on the + * host, because the cache key must name the Xcode that builds the runner. A tool like xcodes swaps + * the Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. + * Like `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`. */ function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { const candidate = chosenDir?.trim() || selectionLinkTarget() || DEFAULT_DEVELOPER_DIR; @@ -135,11 +135,15 @@ function selectionLinkTarget(): string | undefined { if (code === 'ENOENT') return undefined; throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); } - const target = realPath(XCODE_SELECT_LINK_PATH); - if (target === undefined) { - throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, 'dangling selection link'); + try { + return fs.realpathSync(XCODE_SELECT_LINK_PATH); + } catch (error) { + // A link whose target was deleted (an uninstalled Xcode) is not a selection: `xcode-select -p` + // and `xcrun` skip it and use the default Xcode (#4145). + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return undefined; + throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); } - return target; } /** What `xcodebuild -version` prints for the Xcode that owns `developerDir`. */ From b08ccb28ba79650f6485b5bc18e2c63197f6a789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 20:04:47 +0100 Subject: [PATCH 2/4] fix(apple): name a dangling selection link when no default Xcode stands behind it Report danglingSelectionLink and an xcode-select hint when the fallback default Xcode is missing too. Share one ENOENT/unreadable mapping for both selection-link reads. Keep the fixture hermetic against the host's default Xcode, and raise EACCES through the fixture so the case holds as root. --- .../src/__tests__/apple-toolchain-fixtures.ts | 26 +++++--- .../runner-toolchain-identity.test.ts | 50 ++++++++++----- .../src/runner-toolchain-identity.ts | 63 ++++++++++++------- 3 files changed, 95 insertions(+), 44 deletions(-) diff --git a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts index 7f4a9de4a5..2398095260 100644 --- a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts +++ b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts @@ -1,8 +1,8 @@ import { beforeEach, vi } from 'vitest'; import fs from 'node:fs'; -import { XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; - -const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; +import path from 'node:path'; +import { DEFAULT_DEVELOPER_DIR, XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; export { STUBBED_APPLE_TOOLCHAIN, @@ -12,12 +12,14 @@ export { /** * The host's selection link and default Xcode as the toolchain identity sees them. Undefined keeps - * a path absent; a case that owns one points it at a path it created. + * a path absent; a case that owns one points it at a path it created. `targetErrorCode` makes the + * link's target fail to resolve with that code. */ export const appleSelectionLink: { target: string | undefined; + targetErrorCode: string | undefined; defaultDeveloperDir: string | undefined; -} = { target: undefined, defaultDeveloperDir: undefined }; +} = { target: undefined, targetErrorCode: undefined, defaultDeveloperDir: undefined }; /** * Hides the selection link and default Xcode of the machine running the suite, so a case decides the @@ -26,10 +28,17 @@ export const appleSelectionLink: { export function stubAppleSelectionLink(): void { const realRealpathSync = fs.realpathSync; const realReadlinkSync = fs.readlinkSync; - const absent = () => - Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + const failure = (code: string) => Object.assign(new Error(code), { code }); + const absent = () => failure('ENOENT'); beforeEach(() => { + // The host's own default Xcode stays out of reach: an absent default resolves to a path that + // does not exist, so every read under it fails. + const missingDefaultDeveloperDir = path.join( + mkdtempForTestSync('no-default-xcode-'), + 'Xcode.app/Contents/Developer', + ); appleSelectionLink.target = undefined; + appleSelectionLink.targetErrorCode = undefined; appleSelectionLink.defaultDeveloperDir = undefined; vi.spyOn(fs, 'readlinkSync').mockImplementation(((target: fs.PathLike) => { if (String(target) !== XCODE_SELECT_LINK_PATH) return realReadlinkSync(target); @@ -38,11 +47,12 @@ export function stubAppleSelectionLink(): void { }) as typeof fs.readlinkSync); vi.spyOn(fs, 'realpathSync').mockImplementation((target: fs.PathLike) => { if (String(target) === DEFAULT_DEVELOPER_DIR) { - if (appleSelectionLink.defaultDeveloperDir === undefined) throw absent(); + if (appleSelectionLink.defaultDeveloperDir === undefined) return missingDefaultDeveloperDir; return realRealpathSync(appleSelectionLink.defaultDeveloperDir); } if (String(target) !== XCODE_SELECT_LINK_PATH) return realRealpathSync(target); if (appleSelectionLink.target === undefined) throw absent(); + if (appleSelectionLink.targetErrorCode) throw failure(appleSelectionLink.targetErrorCode); return realRealpathSync(appleSelectionLink.target); }); }); diff --git a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts index f8656c3446..20d6dcaa34 100644 --- a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts +++ b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts @@ -80,24 +80,44 @@ test('without DEVELOPER_DIR or a selection link the default Xcode names the fing assert.deepEqual(readFingerprint(), FINGERPRINT_B); }); +test('a dangling selection link without a default Xcode fails naming the dangling link', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.target = path.join(mkdtempForTestSync('deleted-xcode-'), 'Xcode.app'); + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + String(error.details?.file).endsWith(path.join('Xcode.app', 'Contents', 'version.plist')) && + error.details?.danglingSelectionLink === XCODE_SELECT_LINK_PATH, + ); + assert.equal(readRunnerXcodeVersion(), undefined); +}); + +test('no selection link and no default Xcode fails without blaming a selection link', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.danglingSelectionLink === undefined, + ); +}); + test('a selection link whose Xcode this process cannot read fails instead of reading the default Xcode', () => { appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); - const locked = mkdtempForTestSync('locked-xcode-'); - appleSelectionLink.target = path.join(locked, 'Xcode.app'); + appleSelectionLink.target = xcodeA; + appleSelectionLink.targetErrorCode = 'EACCES'; appleSelectionLink.defaultDeveloperDir = xcodeB; - fs.chmodSync(locked, 0o000); - try { - assert.throws( - readFingerprint, - (error: unknown) => - error instanceof AppError && - error.details?.reason === 'apple_toolchain_unreadable' && - error.details?.file === XCODE_SELECT_LINK_PATH && - error.details?.detail === 'EACCES', - ); - } finally { - fs.chmodSync(locked, 0o700); - } + assert.throws( + readFingerprint, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'apple_toolchain_unreadable' && + error.details?.file === XCODE_SELECT_LINK_PATH && + error.details?.detail === 'EACCES', + ); }); test('a selection link this process cannot read fails instead of reading the default Xcode', () => { diff --git a/packages/apple-runner/src/runner-toolchain-identity.ts b/packages/apple-runner/src/runner-toolchain-identity.ts index 284376fabc..c83e3be894 100644 --- a/packages/apple-runner/src/runner-toolchain-identity.ts +++ b/packages/apple-runner/src/runner-toolchain-identity.ts @@ -44,13 +44,21 @@ const SDK_DIRECTORY_NAMES: Record = { export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; /** Where `xcrun` looks when neither `DEVELOPER_DIR` nor the selection link names a developer dir. */ -const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; +export const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; const TOOLCHAIN_FILE_HINT = 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install.'; +const DANGLING_SELECTION_LINK_HINT = + 'The `xcode-select` selection names an Xcode that no longer exists, and no Xcode is installed at the default path. Run `sudo xcode-select -s ` or set DEVELOPER_DIR.'; + type BundleVersion = Readonly<{ version: string; buildVersion: string }>; +/** The host's selection link when it exists: the Xcode it resolves to, or dangling. */ +type SelectionLink = { target: string; dangling?: never } | { target?: never; dangling: true }; + +type SelectedDeveloperDir = Readonly<{ developerDir: string; danglingSelectionLink: boolean }>; + /** A toolchain file that is missing or does not carry the value the identity needs. */ class ToolchainFileError extends Error { readonly file: string; @@ -74,10 +82,12 @@ export function requireRunnerToolchainFingerprint( ): RunnerToolchainFingerprint { if (signal?.aborted) throw createRequestCanceledError({ phase: 'apple_toolchain_identity' }); const chosenDir = commandDeveloperDir(); + let danglingSelectionLink = false; try { - const developerDir = resolveSelectedDeveloperDir(chosenDir); - const xcode = readXcodeVersion(developerDir); - const sdk = readSdkVersion(developerDir, sdkName); + const selected = resolveSelectedDeveloperDir(chosenDir); + danglingSelectionLink = selected.danglingSelectionLink; + const xcode = readXcodeVersion(selected.developerDir); + const sdk = readSdkVersion(selected.developerDir, sdkName); return { xcodeVersion: xcode.version, xcodeBuildVersion: xcode.buildVersion, @@ -95,7 +105,8 @@ export function requireRunnerToolchainFingerprint( ...(chosenDir === undefined ? {} : { developerDir: chosenDir }), file: error.file, detail: error.detail, - hint: TOOLCHAIN_FILE_HINT, + ...(danglingSelectionLink ? { danglingSelectionLink: XCODE_SELECT_LINK_PATH } : {}), + hint: danglingSelectionLink ? DANGLING_SELECTION_LINK_HINT : TOOLCHAIN_FILE_HINT, }, ); } @@ -104,7 +115,8 @@ export function requireRunnerToolchainFingerprint( /** The selected Xcode's version, for a failure report that names it; undefined when unreadable. */ export function readRunnerXcodeVersion(): string | undefined { try { - return readXcodeVersion(resolveSelectedDeveloperDir(commandDeveloperDir())).version; + return readXcodeVersion(resolveSelectedDeveloperDir(commandDeveloperDir()).developerDir) + .version; } catch (error) { if (error instanceof ToolchainFileError) return undefined; throw error; @@ -118,28 +130,37 @@ export function readRunnerXcodeVersion(): string | undefined { * the Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. * Like `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`. */ -function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { - const candidate = chosenDir?.trim() || selectionLinkTarget() || DEFAULT_DEVELOPER_DIR; +function resolveSelectedDeveloperDir(chosenDir: string | undefined): SelectedDeveloperDir { + const chosen = chosenDir?.trim(); + const link = chosen ? undefined : readSelectionLink(); + const candidate = chosen || link?.target || DEFAULT_DEVELOPER_DIR; const resolved = realPath(candidate) ?? candidate; const bundleDeveloperDir = path.join(resolved, 'Contents', 'Developer'); - return fs.existsSync(bundleDeveloperDir) ? bundleDeveloperDir : resolved; + return { + developerDir: fs.existsSync(bundleDeveloperDir) ? bundleDeveloperDir : resolved, + danglingSelectionLink: link?.dangling === true, + }; } -function selectionLinkTarget(): string | undefined { - try { - fs.readlinkSync(XCODE_SELECT_LINK_PATH); - } catch (error) { - // No selection link: `xcrun` falls back to the default Xcode as well. Any other failure is a - // selection this process cannot read, which must not stand in for the default Xcode. - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') return undefined; - throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); +function readSelectionLink(): SelectionLink | undefined { + // No selection link: `xcrun` falls back to the default Xcode as well. + if (readSelectionLinkStep(() => fs.readlinkSync(XCODE_SELECT_LINK_PATH)) === undefined) { + return undefined; } + // A link whose target was deleted (an uninstalled Xcode) is not a selection: `xcode-select -p` + // and `xcrun` skip it and use the default Xcode (#4145). + const target = readSelectionLinkStep(() => fs.realpathSync(XCODE_SELECT_LINK_PATH)); + return target === undefined ? { dangling: true } : { target }; +} + +/** + * One read of the selection link. A missing path is undefined; any other failure is a selection + * this process cannot read, which must not stand in for the default Xcode. + */ +function readSelectionLinkStep(read: () => string): string | undefined { try { - return fs.realpathSync(XCODE_SELECT_LINK_PATH); + return read(); } catch (error) { - // A link whose target was deleted (an uninstalled Xcode) is not a selection: `xcode-select -p` - // and `xcrun` skip it and use the default Xcode (#4145). const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT') return undefined; throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); From 2d51640bf19c53fb9658f46ca108529090690093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 22:04:28 +0100 Subject: [PATCH 3/4] refactor(apple): drop the dangling-link diagnostic for one hint sentence The dangling-link hint asserted a cause the resolver never checked. Return the developer dir as a string again and add the xcode-select -s step to the hint that every toolchain-read failure carries. --- .../runner-toolchain-identity.test.ts | 25 ------------ .../src/runner-toolchain-identity.ts | 40 +++++-------------- 2 files changed, 11 insertions(+), 54 deletions(-) diff --git a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts index 20d6dcaa34..889ba6abf6 100644 --- a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts +++ b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts @@ -80,31 +80,6 @@ test('without DEVELOPER_DIR or a selection link the default Xcode names the fing assert.deepEqual(readFingerprint(), FINGERPRINT_B); }); -test('a dangling selection link without a default Xcode fails naming the dangling link', () => { - appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); - appleSelectionLink.target = path.join(mkdtempForTestSync('deleted-xcode-'), 'Xcode.app'); - assert.throws( - readFingerprint, - (error: unknown) => - error instanceof AppError && - error.details?.reason === 'apple_toolchain_unreadable' && - String(error.details?.file).endsWith(path.join('Xcode.app', 'Contents', 'version.plist')) && - error.details?.danglingSelectionLink === XCODE_SELECT_LINK_PATH, - ); - assert.equal(readRunnerXcodeVersion(), undefined); -}); - -test('no selection link and no default Xcode fails without blaming a selection link', () => { - appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); - assert.throws( - readFingerprint, - (error: unknown) => - error instanceof AppError && - error.details?.reason === 'apple_toolchain_unreadable' && - error.details?.danglingSelectionLink === undefined, - ); -}); - test('a selection link whose Xcode this process cannot read fails instead of reading the default Xcode', () => { appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); appleSelectionLink.target = xcodeA; diff --git a/packages/apple-runner/src/runner-toolchain-identity.ts b/packages/apple-runner/src/runner-toolchain-identity.ts index c83e3be894..a4c33645f6 100644 --- a/packages/apple-runner/src/runner-toolchain-identity.ts +++ b/packages/apple-runner/src/runner-toolchain-identity.ts @@ -47,18 +47,10 @@ export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; export const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; const TOOLCHAIN_FILE_HINT = - 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install.'; - -const DANGLING_SELECTION_LINK_HINT = - 'The `xcode-select` selection names an Xcode that no longer exists, and no Xcode is installed at the default path. Run `sudo xcode-select -s ` or set DEVELOPER_DIR.'; + 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install. If /var/db/xcode_select_link names a deleted Xcode, run `sudo xcode-select -s `.'; type BundleVersion = Readonly<{ version: string; buildVersion: string }>; -/** The host's selection link when it exists: the Xcode it resolves to, or dangling. */ -type SelectionLink = { target: string; dangling?: never } | { target?: never; dangling: true }; - -type SelectedDeveloperDir = Readonly<{ developerDir: string; danglingSelectionLink: boolean }>; - /** A toolchain file that is missing or does not carry the value the identity needs. */ class ToolchainFileError extends Error { readonly file: string; @@ -82,12 +74,10 @@ export function requireRunnerToolchainFingerprint( ): RunnerToolchainFingerprint { if (signal?.aborted) throw createRequestCanceledError({ phase: 'apple_toolchain_identity' }); const chosenDir = commandDeveloperDir(); - let danglingSelectionLink = false; try { - const selected = resolveSelectedDeveloperDir(chosenDir); - danglingSelectionLink = selected.danglingSelectionLink; - const xcode = readXcodeVersion(selected.developerDir); - const sdk = readSdkVersion(selected.developerDir, sdkName); + const developerDir = resolveSelectedDeveloperDir(chosenDir); + const xcode = readXcodeVersion(developerDir); + const sdk = readSdkVersion(developerDir, sdkName); return { xcodeVersion: xcode.version, xcodeBuildVersion: xcode.buildVersion, @@ -105,8 +95,7 @@ export function requireRunnerToolchainFingerprint( ...(chosenDir === undefined ? {} : { developerDir: chosenDir }), file: error.file, detail: error.detail, - ...(danglingSelectionLink ? { danglingSelectionLink: XCODE_SELECT_LINK_PATH } : {}), - hint: danglingSelectionLink ? DANGLING_SELECTION_LINK_HINT : TOOLCHAIN_FILE_HINT, + hint: TOOLCHAIN_FILE_HINT, }, ); } @@ -115,8 +104,7 @@ export function requireRunnerToolchainFingerprint( /** The selected Xcode's version, for a failure report that names it; undefined when unreadable. */ export function readRunnerXcodeVersion(): string | undefined { try { - return readXcodeVersion(resolveSelectedDeveloperDir(commandDeveloperDir()).developerDir) - .version; + return readXcodeVersion(resolveSelectedDeveloperDir(commandDeveloperDir())).version; } catch (error) { if (error instanceof ToolchainFileError) return undefined; throw error; @@ -130,27 +118,21 @@ export function readRunnerXcodeVersion(): string | undefined { * the Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. * Like `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`. */ -function resolveSelectedDeveloperDir(chosenDir: string | undefined): SelectedDeveloperDir { - const chosen = chosenDir?.trim(); - const link = chosen ? undefined : readSelectionLink(); - const candidate = chosen || link?.target || DEFAULT_DEVELOPER_DIR; +function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { + const candidate = chosenDir?.trim() || selectionLinkTarget() || DEFAULT_DEVELOPER_DIR; const resolved = realPath(candidate) ?? candidate; const bundleDeveloperDir = path.join(resolved, 'Contents', 'Developer'); - return { - developerDir: fs.existsSync(bundleDeveloperDir) ? bundleDeveloperDir : resolved, - danglingSelectionLink: link?.dangling === true, - }; + return fs.existsSync(bundleDeveloperDir) ? bundleDeveloperDir : resolved; } -function readSelectionLink(): SelectionLink | undefined { +function selectionLinkTarget(): string | undefined { // No selection link: `xcrun` falls back to the default Xcode as well. if (readSelectionLinkStep(() => fs.readlinkSync(XCODE_SELECT_LINK_PATH)) === undefined) { return undefined; } // A link whose target was deleted (an uninstalled Xcode) is not a selection: `xcode-select -p` // and `xcrun` skip it and use the default Xcode (#4145). - const target = readSelectionLinkStep(() => fs.realpathSync(XCODE_SELECT_LINK_PATH)); - return target === undefined ? { dangling: true } : { target }; + return readSelectionLinkStep(() => fs.realpathSync(XCODE_SELECT_LINK_PATH)); } /** From 8b13b82b85885c081569ba04a659d65daea49340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 1 Nov 2026 22:19:28 +0100 Subject: [PATCH 4/4] refactor(apple): name the selection link in the toolchain hint by its constant --- packages/apple-runner/src/runner-toolchain-identity.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/apple-runner/src/runner-toolchain-identity.ts b/packages/apple-runner/src/runner-toolchain-identity.ts index a4c33645f6..6cb6254670 100644 --- a/packages/apple-runner/src/runner-toolchain-identity.ts +++ b/packages/apple-runner/src/runner-toolchain-identity.ts @@ -46,8 +46,7 @@ export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; /** Where `xcrun` looks when neither `DEVELOPER_DIR` nor the selection link names a developer dir. */ export const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; -const TOOLCHAIN_FILE_HINT = - 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install. If /var/db/xcode_select_link names a deleted Xcode, run `sudo xcode-select -s `.'; +const TOOLCHAIN_FILE_HINT = `The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that \`xcode-select -p\` or DEVELOPER_DIR names a full Xcode install. If ${XCODE_SELECT_LINK_PATH} names a deleted Xcode, run \`sudo xcode-select -s \`.`; type BundleVersion = Readonly<{ version: string; buildVersion: string }>;