diff --git a/.changeset/prepared-simulator-worker.md b/.changeset/prepared-simulator-worker.md new file mode 100644 index 0000000000..858adb5565 --- /dev/null +++ b/.changeset/prepared-simulator-worker.md @@ -0,0 +1,6 @@ +--- +'@expo/worker': patch +'@expo/build-tools': patch +--- + +Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. diff --git a/packages/build-tools/src/utils/IosSimulatorUtils.ts b/packages/build-tools/src/utils/IosSimulatorUtils.ts index 9196548202..983c8f55ab 100644 --- a/packages/build-tools/src/utils/IosSimulatorUtils.ts +++ b/packages/build-tools/src/utils/IosSimulatorUtils.ts @@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises'; import { z } from 'zod'; import { retryAsync } from './retry'; +import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup'; export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>; export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>; @@ -119,6 +120,7 @@ export namespace IosSimulatorUtils { destinationDeviceName: IosSimulatorName; env: NodeJS.ProcessEnv; }): Promise { + await waitForWarmSimulatorCleanupAsync(env); await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], { env, }); @@ -131,6 +133,7 @@ export namespace IosSimulatorUtils { deviceIdentifier: IosSimulatorUuid | IosSimulatorName; env: NodeJS.ProcessEnv; }): Promise { + await waitForWarmSimulatorCleanupAsync(env); try { const devices = await getAvailableDevicesAsync({ env, filter: 'available' }); const device = devices.find( @@ -231,6 +234,7 @@ export namespace IosSimulatorUtils { env: NodeJS.ProcessEnv; launchdEnvironment?: Record; }): Promise { + await waitForWarmSimulatorCleanupAsync(env); const bootEnv = { ...env }; for (const [name, value] of Object.entries(launchdEnvironment)) { bootEnv[`SIMCTL_CHILD_${name}`] = value; @@ -253,6 +257,7 @@ export namespace IosSimulatorUtils { deviceIdentifier: IosSimulatorUuid | IosSimulatorName; env: NodeJS.ProcessEnv; }): Promise<{ udid: IosSimulatorUuid }> { + await waitForWarmSimulatorCleanupAsync(env); const bootstatusResult = await spawn( 'xcrun', ['simctl', 'bootstatus', deviceIdentifier, '-b'], diff --git a/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts index bc520bb2c5..cc40101440 100644 --- a/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { IosSimulatorUtils } from '../IosSimulatorUtils'; import { retryAsync } from '../retry'; +import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, @@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({ jest.mock('../retry', () => ({ retryAsync: jest.fn(async fn => await fn(0)), })); +jest.mock('../warmSimulatorCleanup'); const mockedSpawn = jest.mocked(spawn); @@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => { beforeEach(() => { mockedSpawn.mockReset(); mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any); + jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined); }); describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => { @@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => { }) as any); } + it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => { + let finishCleanup: (() => void) | undefined; + jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce( + async () => + await new Promise(resolve => { + finishCleanup = resolve; + }) + ); + mockAvailableDevices([device()]); + + const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({ + deviceIdentifier: device().udid as any, + env: {}, + }); + expect(mockedSpawn).not.toHaveBeenCalled(); + expect(finishCleanup).toBeDefined(); + finishCleanup?.(); + await setup; + + expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), { + env: {}, + }); + }); + it('writes accessibility prefs for an exact shutdown simulator match', async () => { mockAvailableDevices([ device({ diff --git a/packages/build-tools/src/utils/__tests__/warmSimulatorCleanup.test.ts b/packages/build-tools/src/utils/__tests__/warmSimulatorCleanup.test.ts new file mode 100644 index 0000000000..268deb86dd --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/warmSimulatorCleanup.test.ts @@ -0,0 +1,125 @@ +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; + +import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup'; + +const STATE_FILE = '/tmp/expo-simulator-startup.json'; +const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh'; +const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; +jest.mock('@expo/turtle-spawn'); + +async function writeStateAsync(state: object): Promise { + await fs.mkdir('/tmp', { recursive: true }); + await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state })); +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never); +}); +afterEach(() => { + Object.defineProperty(process, 'platform', platform); +}); + +it('continues at once on a VM without warmup', async () => { + await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined(); + expect(spawn).not.toHaveBeenCalled(); +}); + +it('ignores a previous boot cleanup record and reads the current boot only once', async () => { + await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' }); + let finished = false; + const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => { + finished = true; + }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(finished).toBe(false); + await writeStateAsync({ status: 'disabled' }); + await waiting; + expect(spawn).toHaveBeenCalledTimes(1); +}); + +it('does not accept a cleanup record without a boot ID', async () => { + await writeStateAsync({ status: 'disabled', bootUuid: undefined }); + await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow( + 'Warm Simulator cleanup did not finish' + ); +}); + +it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => { + await writeStateAsync({ status: 'disabled' }); + if (mode === 'failed') { + jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed')); + } else { + jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never); + } + await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow(); +}); + +it('waits for cleanup when warmup is installed but has not written state yet', async () => { + await fs.mkdir('/usr/local/share/vm-tools', { recursive: true }); + await fs.writeFile(STARTUP_SCRIPT, ''); + let finished = false; + const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => { + finished = true; + }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(finished).toBe(false); + await writeStateAsync({ status: 'starting' }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(finished).toBe(false); + await writeStateAsync({ status: 'disabled' }); + await waiting; +}); + +it('times out when installed warmup never writes state', async () => { + await fs.mkdir('/usr/local/share/vm-tools', { recursive: true }); + await fs.writeFile(STARTUP_SCRIPT, ''); + await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow( + 'Warm Simulator cleanup did not finish; the Simulator cannot be used.' + ); +}); + +it('waits until the launcher records that cleanup finished', async () => { + await writeStateAsync({ status: 'ready' }); + let finished = false; + const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => { + finished = true; + }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(finished).toBe(false); + await writeStateAsync({ status: 'stopping' }); + await new Promise(resolve => setTimeout(resolve, 30)); + expect(finished).toBe(false); + await writeStateAsync({ status: 'disabled' }); + await waiting; + expect(finished).toBe(true); +}); + +it('fails with the recorded error when cleanup does not finish in time', async () => { + await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' }); + await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow( + 'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.' + ); +}); + +it('stops waiting when the job is cancelled', async () => { + await writeStateAsync({ status: 'stopping' }); + const controller = new AbortController(); + const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal }); + controller.abort(new Error('cancelled')); + await expect(waiting).rejects.toThrow('cancelled'); +}); + +it('does not wait for a job that uses the warm Simulator', async () => { + await writeStateAsync({ status: 'claimed' }); + await expect( + waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 }) + ).resolves.toBeUndefined(); +}); + +it('does nothing outside macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + await writeStateAsync({ status: 'ready' }); + await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined(); +}); diff --git a/packages/build-tools/src/utils/warmSimulatorCleanup.ts b/packages/build-tools/src/utils/warmSimulatorCleanup.ts new file mode 100644 index 0000000000..dd7925c815 --- /dev/null +++ b/packages/build-tools/src/utils/warmSimulatorCleanup.ts @@ -0,0 +1,61 @@ +import { SystemError } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; + +const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json'; +const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh'; + +/** + * The worker stops warmup in the background for a job that does not use the warm Simulator. Wait + * here, before the job changes a Simulator, until the launcher records that its cleanup finished. + */ +export async function waitForWarmSimulatorCleanupAsync( + env: NodeJS.ProcessEnv, + { + timeoutMs = 150_000, + pollMs = 250, + signal, + }: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {} +): Promise { + if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') { + return; + } + const deadline = Date.now() + timeoutMs; + let bootUuid: string | undefined; + for (;;) { + signal?.throwIfAborted(); + let state: { status?: unknown; error?: unknown; bootUuid?: unknown }; + try { + state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) { + return; + } + // Warmup may still be loading; only disabled confirms cleanup on an enabled VM. + state = {}; + } + if (state.status === 'disabled' && typeof state.bootUuid === 'string') { + bootUuid ??= ( + await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { + signal, + timeout: Math.max(1, deadline - Date.now()), + }) + ).stdout.trim(); + if (!bootUuid) { + throw new SystemError('Could not read the current VM boot ID.'); + } + if (state.bootUuid === bootUuid) { + return; + } + } + if (Date.now() >= deadline) { + throw new SystemError( + `Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.` + ); + } + // The signal is checked at each poll, so a cancelled job stops within one interval. + await sleepAsync(pollMs); + } +} diff --git a/packages/worker/src/__unit__/context.test.ts b/packages/worker/src/__unit__/context.test.ts index 1842bed38a..c9678a01d2 100644 --- a/packages/worker/src/__unit__/context.test.ts +++ b/packages/worker/src/__unit__/context.test.ts @@ -4,9 +4,11 @@ import { ManagedArtifactType, Platform, Workflow } from '@expo/eas-build-job'; import config from '../config'; import { createBuildContext } from '../context'; import { getBuildEnv } from '../env'; +import { maybeStopWarmSimulatorAsync } from '../warmSimulator'; import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment'; import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload'; +jest.mock('../warmSimulator'); jest.mock('../env', () => ({ getBuildEnv: jest.fn(() => ({ __API_SERVER_URL: 'http://api.expo.test', @@ -44,6 +46,8 @@ describe(createBuildContext.name, () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(maybeStopWarmSimulatorAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockReset().mockResolvedValue(); jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue(); }); @@ -51,6 +55,70 @@ describe(createBuildContext.name, () => { jest.restoreAllMocks(); }); + it('starts shutdown after runtime settings without blocking setup or context creation', async () => { + let finishStop!: () => void; + let failStop!: (error: Error) => void; + const stopping = new Promise((resolve, reject) => { + finishStop = resolve; + failStop = reject; + }); + jest.mocked(maybeStopWarmSimulatorAsync).mockReturnValue(stopping); + const job = { platform: Platform.IOS } as any; + let created = false; + const creating = createBuildContext({ ...baseOptions, job }).then(ctx => { + created = true; + return ctx; + }); + try { + for (let turn = 0; turn < 20 && !created; turn++) { + await Promise.resolve(); + } + expect(created).toBe(true); + expect(maybeStopWarmSimulatorAsync).toHaveBeenCalledWith(job); + expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(maybeStopWarmSimulatorAsync).mock.invocationCallOrder[0] + ); + expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1); + const error = new Error('Late Simulator shutdown failure'); + failStop(error); + await new Promise(resolve => setImmediate(resolve)); + expect(childLogger.warn).toHaveBeenCalledWith( + { err: error }, + 'Could not stop warm Simulator services.' + ); + } finally { + finishStop(); + await creating; + } + }); + + it('warns and continues setup when shutdown rejects immediately', async () => { + const error = new Error('Simulator stop failed'); + jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(error); + await createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any }); + expect(childLogger.warn).toHaveBeenCalledWith( + { err: error }, + 'Could not stop warm Simulator services.' + ); + expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1); + expect(getBuildEnv).toHaveBeenCalledTimes(1); + }); + + it('preserves an unrelated setup failure even if background shutdown also fails', async () => { + const stopError = new Error('Simulator stop failed'); + const setupError = new Error('Could not prepare runtime config'); + jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(stopError); + jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockRejectedValueOnce(setupError); + await expect( + createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any }) + ).rejects.toBe(setupError); + expect(childLogger.warn).toHaveBeenCalledWith( + { err: stopError }, + 'Could not stop warm Simulator services.' + ); + expect(getBuildEnv).not.toHaveBeenCalled(); + }); + it('loads runtime settings with the job environment before creating build env', async () => { const env = { EAS_USE_NPM_CACHE: '1' }; diff --git a/packages/worker/src/__unit__/warmSimulator.test.ts b/packages/worker/src/__unit__/warmSimulator.test.ts new file mode 100644 index 0000000000..36c37f6cf9 --- /dev/null +++ b/packages/worker/src/__unit__/warmSimulator.test.ts @@ -0,0 +1,135 @@ +import { type Job } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; + +import { maybeStopWarmSimulatorAsync } from '../warmSimulator'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('node:fs/promises', () => ({ access: jest.fn(), readFile: jest.fn() })); + +const baseJob = {} as Job; +const originalPlatform = process.platform; +const startup = '/usr/local/share/vm-tools/startup.sh'; +const state = '/tmp/expo-simulator-startup.json'; +let environment: NodeJS.ProcessEnv; +beforeEach(() => { + environment = { ...process.env }; + delete process.env.EAS_OVERRIDE_PACKAGE_MANAGER; + delete process.env.EAS_FALLBACK_PACKAGE_MANAGER; + jest.mocked(fs.access).mockReset().mockResolvedValue(); + jest + .mocked(fs.readFile) + .mockReset() + .mockResolvedValue( + JSON.stringify({ + bootUuid: 'current-boot', + status: 'disabled', + launcherVersion: '25.3.0', + }) + ); + jest + .mocked(spawn) + .mockReset() + .mockResolvedValue({ stdout: '' } as Awaited>); + Object.defineProperty(process, 'platform', { value: 'darwin' }); +}); +afterEach(() => { + process.env = environment; + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +it('delegates state and boot handling to one launcher stop command', async () => { + await maybeStopWarmSimulatorAsync({} as Job); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith('bun', ['x', '@expo/simulator-launcher@latest', 'stop'], { + env: expect.objectContaining({ PATH: process.env.PATH }), + stdio: 'pipe', + timeout: 150_000, + }); +}); + +it('waits for the launcher to confirm cleanup', async () => { + let finish!: (value: Awaited>) => void; + jest.mocked(spawn).mockReturnValue( + new Promise(resolve => { + finish = resolve; + }) as ReturnType + ); + let finished = false; + const stopping = maybeStopWarmSimulatorAsync({} as Job).then(() => { + finished = true; + }); + for (let turn = 0; turn < 20 && !finish; turn++) { + await Promise.resolve(); + } + expect(finish).toBeDefined(); + expect(finished).toBe(false); + finish({ stdout: '' } as Awaited>); + await stopping; + expect(finished).toBe(true); +}); + +it.each(['cleanup failed', "Could not find an existing 'simulator-launcher' binary --no-install"])( + 'propagates stop failure without retrying: %s', + async stderr => { + const error = Object.assign(new Error(stderr), { status: 1, stdout: '', stderr }); + jest.mocked(spawn).mockRejectedValueOnce(error); + await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error); + expect(spawn).toHaveBeenCalledTimes(1); + } +); + +it('does nothing on non-Darwin', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + await maybeStopWarmSimulatorAsync({} as Job); + expect(fs.access).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); +}); +it('keeps warmup only for the job opt-in, independently of the host flag', async () => { + await maybeStopWarmSimulatorAsync({ + ...baseJob, + builderEnvironment: { image: 'test-image', env: { EAS_USE_WARM_SIMULATOR: '1' } }, + }); + expect(fs.access).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + process.env.EAS_USE_WARM_SIMULATOR = '1'; + await maybeStopWarmSimulatorAsync({} as Job); + expect(spawn).toHaveBeenCalledTimes(1); +}); +it.each([startup, state])('stops when the image has only %s', async present => { + jest.mocked(fs.access).mockImplementation(async file => { + if (file !== present) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + } + }); + await maybeStopWarmSimulatorAsync({} as Job); + expect(spawn).toHaveBeenCalledTimes(1); +}); +it('makes no package-manager call on an image with neither warmup file', async () => { + jest.mocked(fs.access).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + await maybeStopWarmSimulatorAsync({} as Job); + expect(spawn).not.toHaveBeenCalled(); +}); +it.each([startup, state])('fails closed if checking %s fails', async inaccessible => { + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + jest.mocked(fs.access).mockImplementation(async file => { + if (file === inaccessible) { + throw error; + } + }); + await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error); + expect(spawn).not.toHaveBeenCalled(); +}); +it('preserves the job package-manager override and environment', async () => { + process.env.EAS_OVERRIDE_PACKAGE_MANAGER = 'bun'; + await maybeStopWarmSimulatorAsync({ + ...baseJob, + builderEnvironment: { image: 'test-image', env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' } }, + }); + expect(spawn).toHaveBeenCalledWith('npx', ['--yes', '@expo/simulator-launcher@latest', 'stop'], { + env: expect.objectContaining({ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm', PATH: process.env.PATH }), + stdio: 'pipe', + timeout: 150_000, + }); +}); diff --git a/packages/worker/src/context.ts b/packages/worker/src/context.ts index ffab76b6af..69af371eba 100644 --- a/packages/worker/src/context.ts +++ b/packages/worker/src/context.ts @@ -8,6 +8,7 @@ import config from './config'; import { getBuildEnv } from './env'; import { Analytics } from './external/analytics'; import { uploadXcodeBuildLogs } from './ios/xcodeLogs'; +import { maybeStopWarmSimulatorAsync } from './warmSimulator'; import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment'; import sentry from './sentry'; import { @@ -42,6 +43,10 @@ export async function createBuildContext({ environment: config.env, env: job.builderEnvironment?.env, }); + // Build-tools waits for the launcher's own cleanup record before it changes a Simulator. + void maybeStopWarmSimulatorAsync(job).catch(err => { + childLogger.warn({ err }, 'Could not stop warm Simulator services.'); + }); await prepareRuntimeEnvironmentConfigFiles(); const env = getBuildEnv({ job, projectId, metadata, buildId }); diff --git a/packages/worker/src/warmSimulator.ts b/packages/worker/src/warmSimulator.ts new file mode 100644 index 0000000000..6369745263 --- /dev/null +++ b/packages/worker/src/warmSimulator.ts @@ -0,0 +1,40 @@ +import { + PackageManager, + resolveConfiguredPackageManager, + resolvePackageExec, +} from '@expo/build-tools/dist/utils/packageManager'; +import { type Job } from '@expo/eas-build-job'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; + +/** Request warmup shutdown unless this job opts into warm Simulator adoption. */ +export async function maybeStopWarmSimulatorAsync(job: Job): Promise { + if ( + process.platform !== 'darwin' || + job.builderEnvironment?.env?.EAS_USE_WARM_SIMULATOR === '1' + ) { + return; + } + const warmupFiles = await Promise.all( + ['/usr/local/share/vm-tools/startup.sh', '/tmp/expo-simulator-startup.json'].map(file => + fs.access(file).then( + () => true, + error => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + ) + ) + ); + if (!warmupFiles.some(Boolean)) { + return; + } + const env = { ...process.env, ...job.builderEnvironment?.env }; + const { command, args } = resolvePackageExec( + resolveConfiguredPackageManager(env, PackageManager.BUN), + ['@expo/simulator-launcher@latest', 'stop'] + ); + await spawn(command, args, { env, stdio: 'pipe', timeout: 150_000 }); +}