diff --git a/packages/core/index.ts b/packages/core/index.ts index 359fea266..5568578e3 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -132,6 +132,7 @@ export interface BuildRunOptions { variant?: string; abi?: string; allArch?: boolean; + arch?: string; configuration?: string; scheme?: string; buildConfiguration?: string; @@ -165,9 +166,20 @@ function buildTarget(options: BuildRunOptions): string { return `on-${slug(name)}`; } +/** The simulator slice a local build compiles for this machine: `arm64` or `x86_64`. */ +export function hostSimulatorArch(arch: string = process.arch): 'arm64' | 'x86_64' { + return arch === 'x64' ? 'x86_64' : 'arm64'; +} + export function buildCacheKey(platform: string, fingerprintHash: string, options: unknown = {}): string { const opts = (options && typeof options === 'object' ? options : {}) as BuildRunOptions; - const abi = platform === 'android' && typeof opts.abi === 'string' ? slug(opts.abi) : ''; + const target = buildTarget(opts); + const abi = + platform === 'android' && typeof opts.abi === 'string' + ? slug(opts.abi) + : platform === 'ios' && target === 'sim' && typeof opts.arch === 'string' + ? slug(opts.arch) + : ''; const allArch = platform === 'android' && opts.allArch === true ? '-all-arch' : ''; const compiler = typeof opts.compiler === 'string' ? slug(opts.compiler) : ''; const profile = typeof opts.buildProfile === 'string' ? slug(opts.buildProfile) : ''; @@ -175,7 +187,7 @@ export function buildCacheKey(platform: string, fingerprintHash: string, options platform === 'ios' && typeof opts.scheme === 'string' && opts.scheme ? createHash('sha256').update(opts.scheme).digest('hex') : ''; - return `${fingerprintHash}-${buildVariant(platform, opts)}-${buildTarget(opts)}${abi ? `-${abi}` : ''}${allArch}${compiler ? `-${compiler}` : ''}${profile ? `-${profile}` : ''}${scheme ? `-scheme-${scheme}` : ''}`; + return `${fingerprintHash}-${buildVariant(platform, opts)}-${target}${abi ? `-${abi}` : ''}${allArch}${compiler ? `-${compiler}` : ''}${profile ? `-${profile}` : ''}${scheme ? `-scheme-${scheme}` : ''}`; } export interface RegisterOptions { diff --git a/packages/expo-build-cache/README.md b/packages/expo-build-cache/README.md index cf2ac4fe0..5b741de24 100644 --- a/packages/expo-build-cache/README.md +++ b/packages/expo-build-cache/README.md @@ -38,6 +38,11 @@ per-ABI builds `stim android` makes by default; a universal debug build that `stim android` makes with `optimizations.android.targetAbiOnly: false` is not shared with Expo runs. +An iOS Debug simulator build compiles only this Mac's architecture, so the +provider keys it on `arm64` or `x86_64`, matching `stim ios`. Other iOS +configurations and `--device generic` keep the project's architectures and +their unsuffixed key. + The provider works without the `stim` npm package. When Stim is available, the provider registers its cache so `stim gc` can report and trim it. diff --git a/packages/expo-build-cache/index.ts b/packages/expo-build-cache/index.ts index 64ae5aa99..53ec6b717 100644 --- a/packages/expo-build-cache/index.ts +++ b/packages/expo-build-cache/index.ts @@ -5,6 +5,7 @@ import { artifactIn, buildCacheRoot, buildCacheKey, + hostSimulatorArch, registerCache, resolveArtifact, storeArtifact, @@ -74,10 +75,19 @@ function connectedDeviceAbi(): string | null { } } -// Expo `run:android` builds a `debug` or `debugOptimized` build type without `--all-arch` -// only for the ABI of the device it selected, and boots that device before asking the -// provider for a build. function keyOptions(platform: string, runOptions: RunOptions = {}): RunOptions | null { + // Expo `run:ios` targets the booted simulator, so Xcode compiles a Debug build for this Mac's + // architecture only; other configurations and the generic destination keep the project's ARCHS. + // `--device ` keys as on- with no arch: Expo's name cannot tell a simulator from a phone. + if (platform === 'ios') { + const configuration = runOptions.configuration ?? runOptions.buildConfiguration; + if (runOptions.arch || runOptions.device === 'generic') return runOptions; + if (configuration && configuration.trim().toLowerCase() !== 'debug') return runOptions; + return { ...runOptions, arch: hostSimulatorArch() }; + } + // Expo `run:android` builds a `debug` or `debugOptimized` build type without `--all-arch` + // only for the ABI of the device it selected, and boots that device before asking the + // provider for a build. if (platform !== 'android' || runOptions.abi || runOptions.allArch) return runOptions; const parts = (runOptions.variant || 'debug').split(/(?=[A-Z])/); let buildType = parts.pop()!; diff --git a/packages/stim-cli/src/__tests__/agent-device.test.ts b/packages/stim-cli/src/__tests__/agent-device.test.ts index 4589e937b..8f7908653 100644 --- a/packages/stim-cli/src/__tests__/agent-device.test.ts +++ b/packages/stim-cli/src/__tests__/agent-device.test.ts @@ -8,6 +8,8 @@ import { isLoopbackDaemon, metroHintFrom, openArgs, + parseHealthHostArch, + readRemoteSimulatorArch, remoteProfile, remoteProfilePath, sessionNameFor, @@ -185,3 +187,64 @@ describe('closeArgs', () => { expect(args[args.indexOf('--remote-config') + 1]).toBe('/w/.stim/agent-device.remote.json'); }); }); + +describe('the remote simulator arch', () => { + test.each([ + ['a daemon reporting its host', { ok: true, hostArch: 'x86_64' }, 'x86_64'], + [ + 'a proxy, whose upstream names the daemon machine', + { hostArch: 'arm64', upstream: { hostArch: 'x86_64' } }, + 'x86_64', + ], + ['a proxy whose upstream predates hostArch', { hostArch: 'x86_64', upstream: { ok: true } }, 'x86_64'], + ['a daemon without hostArch', { ok: true, service: 'agent-device-daemon' }, null], + ['an arch Stim cannot build', { hostArch: 'riscv64' }, null], + ['a non-object body', 'arm64', null], + ])('%s', (_name, body, expected) => { + expect(parseHealthHostArch(body)).toBe(expected); + }); + + function respond(body: unknown, ok = true): typeof fetch { + return (async () => ({ ok, json: async () => body })) as unknown as typeof fetch; + } + + test('reads /health under the daemon base URL', async () => { + const urls: string[] = []; + const fetchImpl = (async (url: string) => { + urls.push(url); + return { ok: true, json: async () => ({ hostArch: 'x86_64' }) }; + }) as unknown as typeof fetch; + expect( + await readRemoteSimulatorArch({ baseUrl: 'https://proxy.example/agent-device/' }, { fetch: fetchImpl }), + ).toBe('x86_64'); + expect(urls).toEqual(['https://proxy.example/agent-device/health']); + }); + + test.each([ + ['no daemon', null, respond({ hostArch: 'x86_64' })], + ['an absent field', { baseUrl: 'https://d' }, respond({ ok: true })], + ['an error status', { baseUrl: 'https://d' }, respond({ hostArch: 'x86_64' }, false)], + [ + 'a refused connection', + { baseUrl: 'https://d' }, + (async () => Promise.reject(new TypeError('fetch failed'))) as typeof fetch, + ], + [ + 'a body that is not JSON', + 'https://d', + (async () => ({ ok: true, json: async () => Promise.reject(new SyntaxError('x')) })) as unknown as typeof fetch, + ], + ] as [string, { baseUrl: string } | null, typeof fetch][])('%s means arm64', async (_name, baseUrl, fetchImpl) => { + expect(await readRemoteSimulatorArch(baseUrl, { fetch: fetchImpl })).toBe('arm64'); + }); + + test('a daemon that does not answer in time means arm64', async () => { + const hanging = ((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason)); + })) as unknown as typeof fetch; + const started = Date.now(); + expect(await readRemoteSimulatorArch({ baseUrl: 'https://d' }, { fetch: hanging, timeoutMs: 20 })).toBe('arm64'); + expect(Date.now() - started).toBeLessThan(5_000); + }); +}); diff --git a/packages/stim-cli/src/__tests__/build-cache.test.ts b/packages/stim-cli/src/__tests__/build-cache.test.ts index ede899609..03403c9f5 100644 --- a/packages/stim-cli/src/__tests__/build-cache.test.ts +++ b/packages/stim-cli/src/__tests__/build-cache.test.ts @@ -255,6 +255,15 @@ test('android cache keys separate target ABIs while preserving the universal key expect(buildCacheKey('ios', hash, { abi: 'arm64-v8a' })).toBe(`${hash}-debug-sim`); }); +test('ios simulator keys separate single-arch builds while a universal build keeps its key', () => { + const hash = 'abc123'; + expect(buildCacheKey('ios', hash, { isSimulator: true, arch: 'arm64' })).toBe(`${hash}-debug-sim-arm64`); + expect(buildCacheKey('ios', hash, { isSimulator: true, arch: 'x86_64' })).toBe(`${hash}-debug-sim-x86-64`); + expect(buildCacheKey('ios', hash, { configuration: 'Release', isSimulator: true })).toBe(`${hash}-release-sim`); + expect(buildCacheKey('ios', hash, { isSimulator: false, arch: 'arm64' })).toBe(`${hash}-debug-device`); + expect(buildCacheKey('android', hash, { arch: 'arm64' })).toBe(`${hash}-debug-sim`); +}); + test('the CLI and the Expo provider compute the same key', () => { for (const [platform, options] of [ ['ios', {}], @@ -264,6 +273,7 @@ test('the CLI and the Expo provider compute the same key', () => { ['ios', { device: 'generic' }], ['ios', { device: 'Janic iPhone' }], ['ios', { device: true }], + ['ios', { arch: 'x86_64', scheme: 'App' }], ['android', { variant: 'release', device: 'emulator-5554', abi: 'arm64-v8a' }], ['android', { variant: 'release', compiler: 'apple-cas-012345' }], ] as [string, Record][]) { diff --git a/packages/stim-cli/src/__tests__/cache-packages.test.ts b/packages/stim-cli/src/__tests__/cache-packages.test.ts index 6ecabfc0c..3b4852005 100644 --- a/packages/stim-cli/src/__tests__/cache-packages.test.ts +++ b/packages/stim-cli/src/__tests__/cache-packages.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, wri import { tmpdir } from 'node:os'; import { join } from 'node:path'; import assert from 'node:assert'; -import { METRO_NAMED_CACHE_LAYOUT } from '@stim-cli/core'; +import { METRO_NAMED_CACHE_LAYOUT, hostSimulatorArch } from '@stim-cli/core'; import { readManifest } from '../cache/cache-manifest.ts'; import { sharedBuildCache, sharedMetroCache } from '../workspace/paths.ts'; import { hasStoreAt } from '../supervisor/metro-store.ts'; @@ -30,7 +30,7 @@ test.each(['ios', 'android'])( if (platform === 'ios') mkdirSync(build); const binary = platform === 'ios' ? join(build, 'binary') : build; writeFileSync(binary, 'provider bytes'); - const runOptions = platform === 'android' ? { abi: 'arm64-v8a' } : {}; + const runOptions = platform === 'android' ? { abi: 'arm64-v8a' } : { arch: 'arm64' }; const providerArtifact = await provider.uploadBuildCache({ platform, @@ -189,6 +189,44 @@ test('the standalone Expo build cache provider separates Android ABIs', async () } }); +test('the standalone Expo build cache provider keys iOS Debug simulator builds by this Mac arch', async () => { + const home = mkdtempSync(join(tmpdir(), 'stim-pkg-home-')); + const cacheRoot = mkdtempSync(join(tmpdir(), 'stim-pkg-bc-')); + process.env.STIM_HOME = home; + process.env.STIM_BUILD_CACHE = cacheRoot; + try { + const provider = await import('@stim-cli/expo-build-cache'); + const upload = async (name: string, runOptions: Record) => { + const app = join(home, `${name}.app`); + mkdirSync(app); + writeFileSync(join(app, 'identity'), name); + await provider.uploadBuildCache({ platform: 'ios', fingerprintHash: 'fp', buildPath: app, runOptions }); + }; + const identity = async (runOptions: Record) => { + const app = await provider.resolveBuildCache({ platform: 'ios', fingerprintHash: 'fp', runOptions }); + return app ? readFileSync(join(app, 'identity'), 'utf8') : null; + }; + const other = hostSimulatorArch() === 'arm64' ? 'x86_64' : 'arm64'; + await upload('host', { configuration: 'Debug' }); + await upload('release', { configuration: 'Release' }); + await upload('generic', { device: 'generic' }); + + expect(await identity({ arch: hostSimulatorArch() })).toBe('host'); + expect(await identity({})).toBe('host'); + expect(await identity({ arch: other })).toBeNull(); + expect(await identity({ configuration: 'Release' })).toBe('release'); + expect(await identity({ device: 'generic' })).toBe('generic'); + expect(resolveBuild('ios', buildCacheKey('ios', 'fp', { isSimulator: true, arch: hostSimulatorArch() }))).toBe( + await provider.resolveBuildCache({ platform: 'ios', fingerprintHash: 'fp', runOptions: {} }), + ); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(cacheRoot, { recursive: true, force: true }); + delete process.env.STIM_HOME; + delete process.env.STIM_BUILD_CACHE; + } +}); + test('the Metro cache store registers itself on this Node, at the shard depth', async () => { const home = mkdtempSync(join(tmpdir(), 'stim-pkg-home2-')); const cacheRoot = join(tmpdir(), `stim-pkg-metro-${process.pid}`); diff --git a/packages/stim-cli/src/__tests__/engine-xcode.test.ts b/packages/stim-cli/src/__tests__/engine-xcode.test.ts index ee221e2bc..6f855e74f 100644 --- a/packages/stim-cli/src/__tests__/engine-xcode.test.ts +++ b/packages/stim-cli/src/__tests__/engine-xcode.test.ts @@ -1148,6 +1148,27 @@ describe('buildIos with a mocked executor', () => { await promise; }); + test.each([ + ['a generic destination with an arch builds that one slice', 'arm64', ['ARCHS=arm64', 'ONLY_ACTIVE_ARCH=YES']], + ['a generic destination without an arch leaves ARCHS to the project', null, []], + ])('%s', async (_name, arch, expected) => { + const child = fakeChild(); + const spawnCalls = harness(tmp, { child }); + const promise = buildIos({ + root: tmp, + destination: 'generic/platform=iOS Simulator', + arch, + logWriter: recordingWriter(), + compilationCache: ['COMPILATION_CACHE_ENABLE_CACHING=YES'], + }); + const args = spawnCalls[0]?.args ?? []; + expect(args[args.indexOf('-destination') + 1]).toBe('generic/platform=iOS Simulator'); + expect(args.slice(args.indexOf('build') + 1)).toEqual(['COMPILATION_CACHE_ENABLE_CACHING=YES', ...expected]); + makeProduct(workspaceDerivedData(tmp)); + child.emit('close', 0, null); + await promise; + }); + test('compilationCache: null is how a caller turns it off entirely', async () => { const child = fakeChild(); const spawnCalls = harness(tmp, { child }); diff --git a/packages/stim-cli/src/__tests__/ios-command.test.ts b/packages/stim-cli/src/__tests__/ios-command.test.ts index 0216b3d6e..4a2ed285c 100644 --- a/packages/stim-cli/src/__tests__/ios-command.test.ts +++ b/packages/stim-cli/src/__tests__/ios-command.test.ts @@ -256,6 +256,7 @@ function harness(overrides: LooseDeps = {}) { gitCommonDir: () => null, repoRoot: () => null, detectIsExpo: () => false, + hostSimulatorArch: () => 'arm64', detectBundleId: () => 'com.example.app', devClientScheme: () => undefined, @@ -731,7 +732,7 @@ describe('parked simulator adoption', () => { owned: true, adopted: true, adoptionPending: true, - parkedCacheKey: `${FINGERPRINT}-debug-sim`, + parkedCacheKey: `${FINGERPRINT}-debug-sim-arm64`, }), clearOtherUserApps: () => { events.push('sweep'); @@ -2306,7 +2307,7 @@ describe('success output', () => { expect(facts.udid).toBe(UDID); expect(facts.deviceName).toBe('stim-fixture'); expect(facts.fingerprint).toBe(FINGERPRINT); - expect(facts.cacheKey).toMatch(new RegExp(`^${FINGERPRINT}-debug-sim$`)); + expect(facts.cacheKey).toMatch(new RegExp(`^${FINGERPRINT}-debug-sim-arm64$`)); expect(facts.cacheHit).toBe(false); expect(facts.compilationCache).toEqual({ status: 'unavailable', @@ -2636,7 +2637,7 @@ describe('Contract 4: the state file', () => { expect(state.collectors).toEqual({ android: { pid: 111 } }); expect(state.lastBuild.status).toBe('ok'); expect(state.lastIosBuild).toEqual(state.lastBuild); - expect(state.lastBuild.cacheKey).toBe(`${FINGERPRINT}-debug-sim`); + expect(state.lastBuild.cacheKey).toBe(`${FINGERPRINT}-debug-sim-arm64`); expect(state.lastBuild.bundleId).toBe('com.example.app'); expect(!('errorCode' in state.lastBuild)).toBeTruthy(); }); @@ -3229,6 +3230,56 @@ describe('--remote', () => { }, ); + test.each([ + ['proxy', 'https://proxy.example', 'x86_64', 'x86-64'], + ['eas', null, 'arm64', 'arm64'], + ] as const)( + 'a %s build compiles and keys the remote host arch read once from /health', + async (backend, baseUrl, arch, keyArch) => { + const remote = remoteStub(); + const asked: unknown[] = []; + reserve(); + const resolveRemoteContext = (args: { backend?: unknown }) => { + const resolved = remote.deps.resolveRemoteContext(args); + return { ctx: { ...resolved.ctx, existingDaemon: baseUrl ? { baseUrl, token: 't' } : null } }; + }; + const readRemoteSimulatorArch = async (daemon: { baseUrl: string } | null | undefined) => { + asked.push(daemon?.baseUrl ?? null); + return daemon ? arch : 'arm64'; + }; + const { calls, exitCode } = await run( + { remote: backend }, + { ...remote.deps, resolveRemoteContext, readRemoteSimulatorArch, hostSimulatorArch: () => 'x86_64' }, + ); + expect(exitCode).toBeFalsy(); + expect(asked).toEqual([baseUrl]); + expect(calls.args.buildIos).toMatchObject({ destination: 'generic/platform=iOS Simulator', arch }); + expect(calls.args.storeBuild.key).toBe(`${FINGERPRINT}-debug-sim-${keyArch}`); + }, + ); + + test('a remote Release build is single-arch too, and keys that arch', async () => { + const remote = remoteStub(); + reserve(); + const resolveRemoteContext = (args: { backend?: unknown }) => { + const resolved = remote.deps.resolveRemoteContext(args); + return { ctx: { ...resolved.ctx, existingDaemon: { baseUrl: 'https://proxy.example', token: 't' } } }; + }; + const { calls } = await run( + { remote: 'proxy', configuration: 'Release' }, + { ...remote.deps, resolveRemoteContext, readRemoteSimulatorArch: async () => 'arm64' }, + ); + expect(calls.args.buildIos).toMatchObject({ destination: 'generic/platform=iOS Simulator', arch: 'arm64' }); + expect(calls.args.storeBuild.key).toBe(`${FINGERPRINT}-release-sim-arm64`); + }); + + test('a local simulator build leaves ARCHS to Xcode and keys the host arch', async () => { + reserve(); + const { calls } = await run({}, { hostSimulatorArch: () => 'x86_64' }); + expect(calls.args.buildIos).toMatchObject({ destination: null, arch: null }); + expect(calls.args.storeBuild.key).toBe(`${FINGERPRINT}-debug-sim-x86-64`); + }); + test('a remote start refused on the EAS project lock stops before install, with its notice and remedy', async () => { const remote = remoteStub(); reserve(); @@ -3795,10 +3846,10 @@ describe('release skips Metro entirely', () => { }); describe('the release cache key and the JS swap', () => { - test('the key differs from debug: -release-sim vs -debug-sim', async () => { + test('the key differs from debug: -release-sim vs -debug-sim-arm64', async () => { reserve(); const debugRun = await run({}); - expect(debugRun.calls.args.resolveBuild.key).toBe(`${FINGERPRINT}-debug-sim`); + expect(debugRun.calls.args.resolveBuild.key).toBe(`${FINGERPRINT}-debug-sim-arm64`); const releaseRun = await run({ configuration: 'Release' }); expect(releaseRun.calls.args.resolveBuild.key).toBe(`${FINGERPRINT}-release-sim`); expect(releaseRun.calls.args.storeBuild.platform).toBe('ios'); @@ -4116,7 +4167,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = test('a launch failure after a post-shift hit records the post-shift key, so the next miss has a baseline', async () => { reserve(); - const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim`, 'Fixture.app'); + const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim-arm64`, 'Fixture.app'); const { exitCode } = await run( {}, { @@ -4136,7 +4187,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = test.each([false, true])('a post-shift hit preserves a prior shared-build wait: %s', async (waited) => { reserve(); let acquires = 0; - const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim`, 'Fixture.app'); + const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim-arm64`, 'Fixture.app'); const { logs, errs, calls } = await run( { json: true }, { @@ -4168,7 +4219,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = const facts = parseFirst(logs); expect(facts.cacheHit).toBe('local'); expect(facts.fingerprint).toBe(WARM); - expect(facts.cacheKey).toBe(`${WARM}-debug-sim`); + expect(facts.cacheKey).toBe(`${WARM}-debug-sim-arm64`); expect(facts.waitedForBuild).toEqual(waited ? { pid: 41233, ms: 4000 } : null); expect(errs.join('\n')).not.toMatch(/FAILED without an artifact|RETRY:/); expect(/waited 4s for \/w\/builder's build -> installed from cache/.test(errs.join('\n'))).toBe(waited); @@ -4224,9 +4275,9 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = }, ); expect(lookedUp.length).toBe(2); - expect(lookedUp[1]).toBe(`${WARM}-debug-sim`); + expect(lookedUp[1]).toBe(`${WARM}-debug-sim-arm64`); expect(calls.order.includes('buildIos')).toBe(true); - expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim`); + expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim-arm64`); }); test('a hash that does not move costs no line and stores under the key it looked up', async () => { @@ -4348,7 +4399,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = ); expect(exitCode).toBeNull(); - expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim`); + expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim-arm64`); expect((readWorkspaceState(root) as WorkspaceState).prebuild).toEqual({ ios: WARM }); }); @@ -4356,7 +4407,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = reserve(); const config = join(root, 'app.config.ts'); writeFileSync(config, 'portrait'); - const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim`, 'Fixture.app'); + const cachedApp = join(tmpHome, 'build-cache', 'ios', `${WARM}-debug-sim-arm64`, 'Fixture.app'); const { calls, exitCode } = await run( {}, { @@ -4405,7 +4456,7 @@ describe('re-fingerprint after the steps that rewrite fingerprinted files', () = }, ); - expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim`); + expect(calls.args.storeBuild.key).toBe(`${WARM}-debug-sim-arm64`); expect(errs.join('\n')).toMatch(/aaaaaa\.\. -> bbbbbb\.\. \(after xcodebuild\)/); }); }); @@ -4636,7 +4687,7 @@ describe('the project cache provider', () => { expect(errs.join('\n')).toMatch(/^ {2}cache {7}provider hit \(\.\/cache\.cjs\) -> stored locally$/m); expect(parseFirst(logs).cacheHit).toBe('remote'); expect(seen[0]).toEqual({ name: 'loadCacheProvider', value: { projectRoot: root, config: providerConfig() } }); - expect(seen[1]?.value).toMatchObject({ platform: 'ios', key: `${FINGERPRINT}-debug-sim` }); + expect(seen[1]?.value).toMatchObject({ platform: 'ios', key: `${FINGERPRINT}-debug-sim-arm64` }); }); test('the summary names the provider a hit came from', async () => { @@ -4687,7 +4738,7 @@ describe('the project cache provider', () => { expect(exitCode).toBe(null); expect(calls.order.includes('uploadRemote')).toBe(true); expect(uploads.length).toBe(1); - expect(uploads[0]).toMatchObject({ platform: 'ios', key: `${FINGERPRINT}-debug-sim`, overwrite: false }); + expect(uploads[0]).toMatchObject({ platform: 'ios', key: `${FINGERPRINT}-debug-sim-arm64`, overwrite: false }); expect(errs.join('\n')).toMatch(/^ {2}cache {7}uploaded \(\.\/cache\.cjs\)$/m); }); @@ -6742,7 +6793,18 @@ test('a named iOS run scopes allocation, launch verification, collector and buil }); describe('--plan', () => { - const debugKey = buildCacheKey('ios', FINGERPRINT, { isSimulator: true }); + const debugKey = buildCacheKey('ios', FINGERPRINT, { isSimulator: true, arch: 'arm64' }); + + test('the ios.remote setting is refused: the run keys the remote arch, which a plan cannot read', async () => { + const { logs, exitCode, calls } = await run( + { plan: true, json: true, configuration: 'Release' }, + { resolveSettings: () => ({ ios: { remote: 'proxy' } }) }, + ); + expect(exitCode).toBe(1); + expect(parseFirst(logs)).toMatchObject({ code: 'STIM_BAD_ARG' }); + expect(parseFirst(logs).message).toContain('ios.remote'); + expect(calls.order).not.toContain('fingerprintProject'); + }); function storeEntry(key: string): string { const entry = entryDir('ios', key); @@ -6847,7 +6909,9 @@ describe('--plan', () => { }, ); expect(parseFirst(logs)).toMatchObject({ cacheHit: 'remote', provider: 'probe', outcome: 'hit', prebuild: null }); - expect(asked).toEqual([expect.objectContaining({ fingerprintHash: FINGERPRINT, runOptions: null })]); + expect(asked).toEqual([ + expect.objectContaining({ fingerprintHash: FINGERPRINT, runOptions: { configuration: 'Debug', arch: 'arm64' } }), + ]); expect(existsSync(entryDir('ios', debugKey))).toBe(false); }); diff --git a/packages/stim-cli/src/commands/ios.ts b/packages/stim-cli/src/commands/ios.ts index d32ccd150..ee664185f 100644 --- a/packages/stim-cli/src/commands/ios.ts +++ b/packages/stim-cli/src/commands/ios.ts @@ -68,7 +68,9 @@ import { deviceModelRefusal, isReleaseConfiguration, ownedSimFailure, + simulatorBuildArch, } from './ios/support.ts'; +import type { SimulatorArch } from '../engine/agent-device.ts'; import { lastBuildRecord, writeLastBuild } from './ios/result.ts'; import { finishIosRun, type IosRunCompletion } from './ios/launch.ts'; import { planIos } from './ios/next-build.ts'; @@ -523,6 +525,7 @@ async function runIos( reclaimed = budget.reclaimed; if (budget.refusal) return fail(budget.refusal); let remoteDevice: ReturnType | null = null; + let remoteArch: SimulatorArch | null = null; if (remoteBackend) { const resolved = await d.resolveRemoteContext({ root, @@ -534,6 +537,7 @@ async function runIos( return fail({ code: resolved.code ?? REMOTE_SESSION_ERROR, message: resolved.failed, remedy: resolved.remedy }); } remoteDevice = d.remoteIosDeps(resolved.ctx); + remoteArch = await d.readRemoteSimulatorArch(resolved.ctx.existingDaemon); d = { ...d, checkDeviceCapacity: remoteDevice.checkDeviceCapacity, @@ -804,6 +808,7 @@ async function runIos( buildProfile, isExpo, remoteDestination: Boolean(remoteDevice), + simulatorArch: simulatorBuildArch({ physical, remoteArch, hostArch: d.hostSimulatorArch(), configuration }), device: physical ? { lanAddress, diff --git a/packages/stim-cli/src/commands/ios/artifact.ts b/packages/stim-cli/src/commands/ios/artifact.ts index 9a0f0669d..47178cb18 100644 --- a/packages/stim-cli/src/commands/ios/artifact.ts +++ b/packages/stim-cli/src/commands/ios/artifact.ts @@ -44,8 +44,16 @@ import { workspaceDir } from '../../workspace/paths.ts'; import type { CacheHitLevel, CompilationCacheActivity } from '../../engine/build-facts.ts'; import type { BuildMissReason } from '@stim-cli/core/state'; import type { IosDeps } from './dependencies.ts'; +import type { SimulatorArch } from '../../engine/agent-device.ts'; import { finishIosUpload } from './result.ts'; -import { PLATFORM, isReleaseConfiguration, podAction, printDiagnostics, xcodeFailureReport } from './support.ts'; +import { + PLATFORM, + iosProviderRunOptions, + isReleaseConfiguration, + podAction, + printDiagnostics, + xcodeFailureReport, +} from './support.ts'; import type { BuildFailureFields, FailArgs, RemoteUploadLike, WaitedForBuild } from './types.ts'; // xcodebuild cannot target a remote simulator UDID, so remote builds use the generic destination. @@ -59,6 +67,7 @@ interface IosArtifactRequest { logFile: string; udid: string; remoteDestination: boolean; + simulatorArch: SimulatorArch | null; device: { lanAddress: string | null; metroPort: number | null; @@ -160,6 +169,7 @@ export async function acquireIosArtifact( logFile, udid, remoteDestination, + simulatorArch, device, configuration, buildScheme, @@ -180,8 +190,10 @@ export async function acquireIosArtifact( scheme: buildScheme, ...(configuration ? { configuration } : {}), isSimulator: !physical, + ...(simulatorArch ? { arch: simulatorArch } : {}), ...(buildProfile ? { buildProfile } : {}), }; + const providerRunOptions = iosProviderRunOptions(configuration, simulatorArch); const lanAddress = device?.lanAddress ?? null; const metroPort = device?.metroPort ?? null; const release = isReleaseConfiguration(configuration); @@ -355,7 +367,7 @@ export async function acquireIosArtifact( platform: PLATFORM, projectRoot: root, fingerprintHash: fingerprint, - runOptions: configuration ? { configuration } : null, + runOptions: providerRunOptions, }); if (hit?.appPath) { let stored = null; @@ -767,6 +779,7 @@ export async function acquireIosArtifact( scheme: buildScheme, udid, destination: remoteDestination ? GENERIC_SIM_DESTINATION : null, + arch: remoteDestination ? simulatorArch : null, ...(physical ? { sdk: IPHONEOS_SDK } : {}), logWriter: logWriter(), ...(configuration ? { configuration } : {}), @@ -829,7 +842,7 @@ export async function acquireIosArtifact( projectRoot: root, fingerprintHash: storeHash, buildPath: appPath!, - runOptions: configuration ? { configuration } : null, + runOptions: providerRunOptions, }); } } diff --git a/packages/stim-cli/src/commands/ios/dependencies.ts b/packages/stim-cli/src/commands/ios/dependencies.ts index 22d4be2fa..7725f8108 100644 --- a/packages/stim-cli/src/commands/ios/dependencies.ts +++ b/packages/stim-cli/src/commands/ios/dependencies.ts @@ -18,6 +18,8 @@ import { remoteIosDeps, resolveRemoteContext, } from '../../engine/device-remote.ts'; +import { readRemoteSimulatorArch } from '../../engine/agent-device.ts'; +import { hostSimulatorArch } from '@stim-cli/core'; import { awaitIosDeviceLaunch, installIosDeviceApp, @@ -69,6 +71,8 @@ export interface IosDeps { ensureRemoteBootOwned: typeof ensureRemoteBootOwned; detectProviders: typeof detectProviders; remoteIosDeps: typeof remoteIosDeps; + readRemoteSimulatorArch: typeof readRemoteSimulatorArch; + hostSimulatorArch: typeof hostSimulatorArch; resolveEasCliBin: typeof resolveEasCliBin; findProjectRoot: typeof findProjectRoot; resolveSettings: typeof resolveSettings; @@ -164,6 +168,8 @@ export const DEFAULT_DEPS: IosDeps = { ensureBooted, resolveRemoteContext, remoteIosDeps, + readRemoteSimulatorArch, + hostSimulatorArch, ensureMetroReachable, ensureRemoteBootOwned, detectProviders, diff --git a/packages/stim-cli/src/commands/ios/next-build.ts b/packages/stim-cli/src/commands/ios/next-build.ts index 34f84cc16..81ad83f6a 100644 --- a/packages/stim-cli/src/commands/ios/next-build.ts +++ b/packages/stim-cli/src/commands/ios/next-build.ts @@ -21,6 +21,8 @@ import { resolveConfiguration, resolveDeviceType, resolveRuntime, + simulatorBuildArch, + iosProviderRunOptions, } from './support.ts'; import type { FailArgs, IosCommandOptions } from './types.ts'; @@ -112,6 +114,14 @@ export async function planIos( ); } + const remoteBackend = remoteIosSetting(settings); + if (remoteBackend) { + return refuse({ + code: 'STIM_BAD_ARG', + message: `ios.remote routes this workspace's runs to a ${remoteBackend} device, whose architecture --plan cannot read without a session.`, + remedy: 'Run `stim ios` to build for the remote device, or unset ios.remote to plan the owned simulator.', + }); + } const refusal = schemeRefusal(root, opts.scheme, isExpo); if (refusal) return refuse({ code: refusal.code, message: refusal.message ?? '', remedy: refusal.remedy ?? '' }); const configuration = resolveConfiguration(opts.configuration, settings); @@ -135,10 +145,17 @@ export async function planIos( remedy: 'Check the project native inputs and the @expo/fingerprint error above, then retry.', }); } + const arch = simulatorBuildArch({ + physical: false, + remoteArch: null, + hostArch: d.hostSimulatorArch(), + configuration, + }); const cacheKey = buildCacheKey(PLATFORM, fingerprint.hash, { scheme: opts.scheme, ...(configuration ? { configuration } : {}), isSimulator: true, + ...(arch ? { arch } : {}), ...(buildProfile ? { buildProfile } : {}), }); const plan = await planCachedBuild( @@ -152,7 +169,7 @@ export async function planIos( providerConfig: d.resolveCacheProviderConfig(settingsContext), expoRemote: cachePolicy.remote && !buildProfile && !opts.scheme - ? { runOptions: configuration ? { configuration } : null } + ? { runOptions: iosProviderRunOptions(configuration, arch) } : null, }, { diff --git a/packages/stim-cli/src/commands/ios/support.ts b/packages/stim-cli/src/commands/ios/support.ts index 18e992757..ea2218981 100644 --- a/packages/stim-cli/src/commands/ios/support.ts +++ b/packages/stim-cli/src/commands/ios/support.ts @@ -11,6 +11,7 @@ import { listIosRuntimes } from '../../devices/ios.ts'; import { IosDeviceMismatchError } from '../../engine/device-ios.ts'; import type { RemoteDeviceBackend } from '../../engine/device-remote.ts'; import { describeDiagnostic } from '../../engine/errors-xcode.ts'; +import type { SimulatorArch } from '../../engine/agent-device.ts'; export const PLATFORM = 'ios'; @@ -183,6 +184,30 @@ export function isReleaseConfiguration(configuration: string | null | undefined) ); } +export function simulatorBuildArch({ + physical, + remoteArch, + hostArch, + configuration, +}: { + physical: boolean; + remoteArch: SimulatorArch | null; + hostArch: SimulatorArch; + configuration: string | null | undefined; +}): SimulatorArch | null { + if (physical) return null; + if (remoteArch) return remoteArch; + return isReleaseConfiguration(configuration) ? null : hostArch; +} + +export function iosProviderRunOptions( + configuration: string | null | undefined, + arch: SimulatorArch | null, +): { configuration?: string; arch?: SimulatorArch } | null { + if (arch) return { configuration: configuration || 'Debug', arch }; + return configuration ? { configuration } : null; +} + export function podAction( podState: PodStateLike | null | undefined, verdict: PodVerdictLike | null | undefined, diff --git a/packages/stim-cli/src/engine/agent-device.ts b/packages/stim-cli/src/engine/agent-device.ts index 4c16f0a33..71a538c03 100644 --- a/packages/stim-cli/src/engine/agent-device.ts +++ b/packages/stim-cli/src/engine/agent-device.ts @@ -121,3 +121,35 @@ export function isLoopbackDaemon(baseUrl: string): boolean { } return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]'; } + +export type SimulatorArch = 'arm64' | 'x86_64'; + +const HEALTH_TIMEOUT_MS = 3_000; + +function archField(value: unknown): SimulatorArch | null { + if (!value || typeof value !== 'object') return null; + const arch = (value as { hostArch?: unknown }).hostArch; + return arch === 'arm64' || arch === 'x86_64' ? arch : null; +} + +/** agent-device proxies report the daemon machine under `upstream`; a daemon reports itself at the top level. */ +export function parseHealthHostArch(body: unknown): SimulatorArch | null { + if (!body || typeof body !== 'object') return null; + return archField((body as { upstream?: unknown }).upstream) ?? archField(body); +} + +export async function readRemoteSimulatorArch( + daemon: Pick | null | undefined, + { fetch: fetchImpl = fetch, timeoutMs = HEALTH_TIMEOUT_MS }: { fetch?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + if (!daemon) return 'arm64'; + try { + const response = await fetchImpl(`${daemon.baseUrl.replace(/\/+$/, '')}/health`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) return 'arm64'; + return parseHealthHostArch(await response.json()) ?? 'arm64'; + } catch { + return 'arm64'; + } +} diff --git a/packages/stim-cli/src/engine/xcode.ts b/packages/stim-cli/src/engine/xcode.ts index e328ee869..16d3e2a07 100644 --- a/packages/stim-cli/src/engine/xcode.ts +++ b/packages/stim-cli/src/engine/xcode.ts @@ -684,6 +684,7 @@ export async function buildIos({ configuration = 'Debug', sdk = 'iphonesimulator', destination = null, + arch = null, derivedDataPath = null, extraArgs = [], compilationCache = undefined, @@ -703,6 +704,7 @@ export async function buildIos({ configuration?: string; sdk?: string; destination?: string | null; + arch?: string | null; derivedDataPath?: string | null; extraArgs?: string[]; compilationCache?: string[] | null; @@ -764,10 +766,12 @@ export async function buildIos({ } const buildScheme = selected.scheme as string; - const buildSettings = + const cacheSettings = compilationCache === undefined ? resolveCompilationCacheSettings({ root, derivedDataPath: dd, exec: executor, onNote, optimizations }) : compilationCache || []; + // Xcode forces ONLY_ACTIVE_ARCH=NO for a generic destination; only an explicit ARCHS narrows the build. + const buildSettings = arch ? [...cacheSettings, `ARCHS=${arch}`, 'ONLY_ACTIVE_ARCH=YES'] : cacheSettings; const args = xcodebuildArgs({ project: resolvedTarget, diff --git a/packages/stim-cli/src/guide/facts.ts b/packages/stim-cli/src/guide/facts.ts index 8af1f0603..2f1ca58ac 100644 --- a/packages/stim-cli/src/guide/facts.ts +++ b/packages/stim-cli/src/guide/facts.ts @@ -194,7 +194,12 @@ leased until