Skip to content

Commit cca16c5

Browse files
committed
fix(apple-runner): never compare an unavailable toolchain probe
A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used to fall back to the literal `unknown`, which was memoized for the process and then persisted into the rebuilt cache's metadata, so every later daemon on a healthy host mismatched again and paid a full build-for-testing. Unavailability is now a distinct outcome with no comparable value: only successful probes are memoized, an unreadable toolchain fails the cache decision with a retriable typed error naming the probe that could not answer, and the CI metadata writer refuses to persist a probe it could not read. The cache_metadata_mismatch diagnostic now lists the differing keys with expected and actual values instead of only saying the metadata differed. The runner-source fingerprint moves to the module that owns the runner's source roots, keeping the cache-metadata module within its size budget without adding a module to the Apple facades' eager closure.
1 parent 9d9e93a commit cca16c5

8 files changed

Lines changed: 624 additions & 207 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { beforeEach, vi } from 'vitest';
2+
import { appleRunnerTestHost } from '../test-host.ts';
3+
import type { ExecResult } from '../host.ts';
4+
5+
const STUBBED_APPLE_TOOLCHAIN = {
6+
xcodeVersion: '26.2',
7+
xcodeBuildVersion: '17C52',
8+
sdkVersion: '26.2',
9+
sdkBuildVersion: '23C53',
10+
} as const;
11+
12+
export function appleToolchainProbeResult(command: string, args: readonly string[]): ExecResult {
13+
if (command === 'xcodebuild' && args[0] === '-version') {
14+
return {
15+
exitCode: 0,
16+
stdout: `Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}\nBuild version ${STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion}\n`,
17+
stderr: '',
18+
};
19+
}
20+
if (command === 'xcrun' && args.includes('--show-sdk-build-version')) {
21+
return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkBuildVersion}\n`, stderr: '' };
22+
}
23+
if (command === 'xcrun' && args.includes('--show-sdk-version')) {
24+
return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkVersion}\n`, stderr: '' };
25+
}
26+
throw new Error(`Unexpected Apple toolchain probe: ${command} ${args.join(' ')}`);
27+
}
28+
29+
/**
30+
* Answers the runner cache's toolchain probes from a fixed toolchain, so cases
31+
* that key the cache neither read the host's Xcode nor depend on one existing.
32+
* Returns the mock so a case can make a probe fail.
33+
*/
34+
export function stubAppleToolchainProbes(): ReturnType<typeof vi.fn> {
35+
const runCmdSync = vi.fn(appleToolchainProbeResult);
36+
beforeEach(() => {
37+
runCmdSync.mockImplementation(appleToolchainProbeResult);
38+
appleRunnerTestHost.update({ runCmdSync });
39+
});
40+
return runCmdSync;
41+
}

packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3-
import { onTestFinished, test } from 'vitest';
3+
import { expect, onTestFinished, test } from 'vitest';
44
import assert from 'node:assert/strict';
5+
import { AppError } from '@agent-device/kernel/errors';
56
import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts';
67
import {
8+
diffComparableRunnerCacheMetadata,
79
resolveRunnerBundleBuildSettings,
810
resolveRunnerMaxConcurrentDestinationsFlag,
911
resolveRunnerSigningBuildSettings,
@@ -12,6 +14,9 @@ import {
1214
resolveExpectedRunnerCacheMetadata,
1315
} from '../runner-cache-metadata.ts';
1416
import { mkdtempForTestSync } from './tmp-dir.ts';
17+
import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
18+
19+
const runCmdSync = stubAppleToolchainProbes();
1520

1621
test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => {
1722
assert.equal(
@@ -233,3 +238,156 @@ test('runner cache metadata ignores development-only SwiftPM trees but keeps run
233238
).runnerSourceFingerprint;
234239
assert.notEqual(afterRunnerTestChange, afterIgnoredChanges);
235240
});
241+
242+
test('metadata diff names only the comparable keys that differ, with expected and actual', () => {
243+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
244+
const actual = {
245+
...expected,
246+
packageVersion: `${expected.packageVersion}-next`,
247+
xcodeBuildVersion: '17A100',
248+
runnerPerformanceBuildSettings: ['ENABLE_CODE_COVERAGE=YES'],
249+
artifacts: {
250+
xctestrunPath: '/tmp/derived/Runner.xctestrun',
251+
xctestrunMtimeMs: 1,
252+
xctestrunSize: 2,
253+
productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }],
254+
},
255+
};
256+
257+
assert.deepEqual(diffComparableRunnerCacheMetadata(expected, actual), [
258+
{
259+
key: 'runnerPerformanceBuildSettings',
260+
expected: JSON.stringify(expected.runnerPerformanceBuildSettings),
261+
actual: '["ENABLE_CODE_COVERAGE=YES"]',
262+
},
263+
{ key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' },
264+
]);
265+
});
266+
267+
test('metadata diff reports a key only one side carries as absent', () => {
268+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
269+
const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = expected;
270+
271+
assert.deepEqual(
272+
diffComparableRunnerCacheMetadata(expected, withoutSdkBuildVersion as typeof expected),
273+
[{ key: 'sdkBuildVersion', expected: '"23C53"', actual: '(absent)' }],
274+
);
275+
});
276+
277+
test('metadata diff is empty for identical metadata', () => {
278+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
279+
280+
assert.deepEqual(diffComparableRunnerCacheMetadata(expected, { ...expected }), []);
281+
});
282+
283+
test('metadata diff elides an over-long value in the middle so both ends stay comparable', () => {
284+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
285+
const longSetting = (suffix: string) => [`${'A'.repeat(400)}=${suffix}`];
286+
287+
const [difference] = diffComparableRunnerCacheMetadata(
288+
{ ...expected, runnerBundleBuildSettings: longSetting('one') },
289+
{ ...expected, runnerBundleBuildSettings: longSetting('two') },
290+
);
291+
292+
assert.equal(difference?.key, 'runnerBundleBuildSettings');
293+
assert.ok((difference?.expected.length ?? 0) <= 300);
294+
assert.ok(difference?.expected.startsWith('["AAA'));
295+
assert.ok(difference?.expected.endsWith('=one"]'));
296+
assert.ok(difference?.actual.endsWith('=two"]'));
297+
});
298+
299+
function unavailableProbes(): { probe: string; reason: string }[] {
300+
try {
301+
resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
302+
return [];
303+
} catch (error) {
304+
assert.ok(error instanceof AppError);
305+
assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable');
306+
const probes = error.details?.probes as { probe: string; reason: string }[];
307+
return probes.map(({ probe, reason }) => ({ probe, reason }));
308+
}
309+
}
310+
311+
test('a timed-out probe leaves the toolchain unavailable instead of a comparable value', () => {
312+
runCmdSync.mockImplementation((command: string, args: readonly string[]) => {
313+
if (command === 'xcodebuild') {
314+
throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {
315+
timeoutMs: 5_000,
316+
});
317+
}
318+
return appleToolchainProbeResult(command, args);
319+
});
320+
321+
assert.deepEqual(unavailableProbes(), [{ probe: 'xcodebuild -version', reason: 'probe_error' }]);
322+
});
323+
324+
test('a failing probe reports its exit status rather than a fabricated SDK version', () => {
325+
runCmdSync.mockImplementation((command: string, args: readonly string[]) =>
326+
command === 'xcrun'
327+
? { exitCode: 70, stdout: '', stderr: 'xcrun: error: SDK cannot be located\n' }
328+
: appleToolchainProbeResult(command, args),
329+
);
330+
331+
assert.deepEqual(unavailableProbes(), [
332+
{ probe: 'xcrun --sdk iphonesimulator --show-sdk-version', reason: 'nonzero_exit' },
333+
{ probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', reason: 'nonzero_exit' },
334+
]);
335+
});
336+
337+
test('unrecognized xcodebuild output is unavailable, not a partially parsed fingerprint', () => {
338+
runCmdSync.mockImplementation((command: string, args: readonly string[]) =>
339+
command === 'xcodebuild'
340+
? { exitCode: 0, stdout: 'xcode-select: error: tool not configured\n', stderr: '' }
341+
: appleToolchainProbeResult(command, args),
342+
);
343+
344+
assert.deepEqual(unavailableProbes(), [
345+
{ probe: 'xcodebuild -version', reason: 'unparsable_output' },
346+
]);
347+
});
348+
349+
test('an empty probe answer is unavailable rather than an empty cache key field', () => {
350+
runCmdSync.mockImplementation((command: string, args: readonly string[]) =>
351+
command === 'xcrun' && args.includes('--show-sdk-build-version')
352+
? { exitCode: 0, stdout: '\n', stderr: '' }
353+
: appleToolchainProbeResult(command, args),
354+
);
355+
356+
assert.deepEqual(unavailableProbes(), [
357+
{ probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', reason: 'empty_output' },
358+
]);
359+
});
360+
361+
test('an unavailable toolchain fails the cache decision with a retriable typed error', () => {
362+
runCmdSync.mockImplementation(() => {
363+
throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {});
364+
});
365+
366+
try {
367+
resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
368+
assert.fail('expected an unavailable toolchain to fail the cache decision');
369+
} catch (error) {
370+
assert.ok(error instanceof AppError);
371+
assert.equal(error.code, 'COMMAND_FAILED');
372+
assert.equal(error.details?.retriable, true);
373+
expect(error.message).toContain('xcodebuild -version');
374+
expect(String(error.details?.hint)).toContain('xcode-select');
375+
}
376+
});
377+
378+
test('an unavailable probe never reaches cache metadata, and is not memoized as one', () => {
379+
runCmdSync.mockImplementation(() => {
380+
throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {});
381+
});
382+
expect(() => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR)).toThrow(
383+
/Could not read the Xcode toolchain versions/,
384+
);
385+
386+
runCmdSync.mockImplementation(appleToolchainProbeResult);
387+
const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
388+
389+
assert.equal(metadata.xcodeVersion, '26.2');
390+
assert.equal(metadata.xcodeBuildVersion, '17C52');
391+
assert.equal(metadata.sdkVersion, '26.2');
392+
assert.equal(metadata.sdkBuildVersion, '23C53');
393+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { onTestFinished, test } from 'vitest';
5+
import {
6+
evaluateExistingXctestrun,
7+
writeRunnerCacheMetadata,
8+
type ExistingXctestrunState,
9+
} from '../runner-cache.ts';
10+
import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
11+
import { IOS_SIMULATOR } from './device-fixtures.ts';
12+
import { mkdtempForTestSync } from './tmp-dir.ts';
13+
import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
14+
15+
stubAppleToolchainProbes();
16+
17+
function evaluateAgainstCachedMetadata(
18+
cached: Record<string, unknown>,
19+
): Promise<ExistingXctestrunState> {
20+
const derived = mkdtempForTestSync('agent-device-runner-cache-eval-');
21+
onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true }));
22+
const xctestrunPath = path.join(derived, 'Runner.xctestrun');
23+
fs.writeFileSync(xctestrunPath, 'xctestrun');
24+
writeRunnerCacheMetadata(derived, cached as never);
25+
return evaluateExistingXctestrun({
26+
derived,
27+
projectRoot: process.cwd(),
28+
expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR),
29+
findXctestrun: () => xctestrunPath,
30+
xctestrunReferencesProjectRoot: () => true,
31+
resolveExistingXctestrunProductPaths: () => Promise.resolve([path.join(derived, 'Runner.app')]),
32+
});
33+
}
34+
35+
test('a metadata mismatch names the differing keys with expected and actual', async () => {
36+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
37+
38+
const state = await evaluateAgainstCachedMetadata({
39+
...expected,
40+
xcodeBuildVersion: '17A100',
41+
runnerSandboxBuildArgs: [...expected.runnerSandboxBuildArgs, 'EXTRA=1'],
42+
});
43+
44+
assert.equal(state.reason, 'cache_metadata_mismatch');
45+
assert.deepEqual(state.reason === 'cache_metadata_mismatch' ? state.metadataDifferences : null, [
46+
{
47+
key: 'runnerSandboxBuildArgs',
48+
expected: JSON.stringify(expected.runnerSandboxBuildArgs),
49+
actual: JSON.stringify([...expected.runnerSandboxBuildArgs, 'EXTRA=1']),
50+
},
51+
{ key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' },
52+
]);
53+
});
54+
55+
test('metadata that differs only in the non-comparable fields still reuses the cache', async () => {
56+
const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR);
57+
58+
const state = await evaluateAgainstCachedMetadata({
59+
...expected,
60+
packageVersion: `${expected.packageVersion}-next`,
61+
});
62+
63+
assert.equal(state.reason, 'reuse_ready');
64+
});

packages/platform-apple/src/runner/runner-artifact.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ async function ensureXctestrunUnderCacheLock(params: {
172172
emitRunnerXctestrunDecision('rebuild', existing.reason, {
173173
derived,
174174
xctestrunPath: existing.xctestrunPath,
175+
...(existing.reason === 'cache_metadata_mismatch'
176+
? { metadataDifferences: existing.metadataDifferences }
177+
: {}),
175178
});
176179
}
177180
const reusable = await resolveReusableXctestrunArtifact({

0 commit comments

Comments
 (0)