From 80cd04c5aaf6fefc69808de669c173df41039b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Wed, 29 Jul 2026 18:11:15 +0200 Subject: [PATCH 1/4] Stop simulator job when start is canceled --- .../simulator/__tests__/start.test.ts | 39 +++++ .../eas-cli/src/commands/simulator/start.ts | 142 ++++++++++++++---- 2 files changed, 153 insertions(+), 28 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index 5fe80644a6..293f9f2d61 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -69,6 +69,9 @@ const deviceRunSessionUrl = const mockCreateDeviceRunSessionAsync = jest.mocked( DeviceRunSessionMutation.createDeviceRunSessionAsync ); +const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked( + DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync +); const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync); const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync); @@ -147,6 +150,10 @@ describe(SimulatorStart, () => { jest.clearAllMocks(); delete process.env[EAS_SIMULATOR_SESSION_ID]; mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession()); + mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({ + id: 'session-123', + status: DeviceRunSessionStatus.Stopped, + }); mockByIdAsync.mockResolvedValue(makeDeviceRunSession()); mockLoadSimulatorEnvAsync.mockResolvedValue(); mockResetSimulatorEnvAsync.mockResolvedValue(); @@ -371,6 +378,38 @@ describe(SimulatorStart, () => { ); }); + it('stops the simulator session when interrupted before the session is ready', async () => { + let notifyQueryStarted: () => void = () => {}; + const queryStarted = new Promise(resolve => { + notifyQueryStarted = resolve; + }); + mockByIdAsync.mockImplementationOnce( + () => + new Promise(() => { + notifyQueryStarted(); + }) + ); + const existingSigintListeners = new Set(process.listeners('SIGINT')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + const commandPromise = command.runAsync(); + await queryStarted; + + const sigintHandler = process + .listeners('SIGINT') + .find(listener => !existingSigintListeners.has(listener)); + expect(sigintHandler).toBeDefined(); + sigintHandler?.('SIGINT'); + await commandPromise; + + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir); + expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]); + }); + it('prompts to select the platform when --platform is omitted', async () => { mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Android }); mockByIdAsync diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index de1e95a36d..ade73ac6ef 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -141,6 +141,7 @@ export default class SimulatorStart extends EasCommand { const createSpinner = ora('๐Ÿš€ Creating simulator session').start(); let deviceRunSessionId: string; let deviceRunSessionUrl: string; + let sessionInterrupt: SessionInterrupt | undefined; try { const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, { appId: projectId, @@ -157,6 +158,7 @@ export default class SimulatorStart extends EasCommand { session.app.slug, deviceRunSessionId ); + sessionInterrupt = registerSessionInterrupt(deviceRunSessionId); const simulatorEnvWritten = !jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv ? await writeSimulatorEnvSafelyAsync(projectDir, { @@ -170,6 +172,7 @@ export default class SimulatorStart extends EasCommand { ); } catch (err) { createSpinner.fail('Failed to create simulator session'); + sessionInterrupt?.dispose(); throw err; } @@ -178,8 +181,15 @@ export default class SimulatorStart extends EasCommand { let remoteConfig: DeviceRunSessionRemoteConfig | undefined; try { - while (Date.now() < deadline) { - const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId); + while (!sessionInterrupt.signal.aborted && Date.now() < deadline) { + const session = await Promise.race([ + DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId), + sessionInterrupt.abortPromise, + ]); + + if (!session) { + break; + } if ( session.status === DeviceRunSessionStatus.Errored || @@ -207,17 +217,30 @@ export default class SimulatorStart extends EasCommand { break; } - await sleepAsync(POLL_INTERVAL_MS); + await Promise.race([sleepAsync(POLL_INTERVAL_MS), sessionInterrupt.abortPromise]); } } catch (err) { pollSpinner.fail(`Failed while polling for ${flags.type} session to be ready`); await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId); + sessionInterrupt.dispose(); throw err; } + if (sessionInterrupt.signal.aborted) { + await stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner: pollSpinner, + sessionInterrupt, + }); + return; + } + if (!remoteConfig) { pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`); await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId); + sessionInterrupt.dispose(); throw new Error( `Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}` ); @@ -230,7 +253,19 @@ export default class SimulatorStart extends EasCommand { }); } + if (sessionInterrupt.signal.aborted) { + await stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner: pollSpinner, + sessionInterrupt, + }); + return; + } + if (jsonFlag) { + sessionInterrupt.dispose(); printJsonOnlyOutput({ id: deviceRunSessionId, name, @@ -246,6 +281,7 @@ export default class SimulatorStart extends EasCommand { Log.newLine(); if (nonInteractive) { + sessionInterrupt.dispose(); Log.log( `When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}` ); @@ -257,6 +293,7 @@ export default class SimulatorStart extends EasCommand { deviceRunSessionId, deviceRunSessionUrl, projectDir, + sessionInterrupt, }); } } @@ -307,40 +344,19 @@ async function waitForSessionEndOrInterruptAsync({ deviceRunSessionId, deviceRunSessionUrl, projectDir, + sessionInterrupt, }: { graphqlClient: ExpoGraphqlClient; deviceRunSessionId: string; deviceRunSessionUrl: string; projectDir: string; + sessionInterrupt: SessionInterrupt; }): Promise { const spinner = ora( `Simulator session active โ€” press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell` ).start(); - const abortController = new AbortController(); - const { signal } = abortController; - const abortPromise = new Promise(resolve => { - signal.addEventListener( - 'abort', - () => { - resolve(); - }, - { once: true } - ); - }); - const sigintHandler = (): void => { - if (signal.aborted) { - // Force exit on a second Ctrl+C in case cleanup is hanging. The session may still be - // running on EAS, so tell the user how to make sure it gets terminated. - spinner.fail( - `Aborted before the simulator session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.` - ); - process.exit(130); - } - abortController.abort(); - }; - process.on('SIGINT', sigintHandler); - + const { signal, abortPromise } = sessionInterrupt; try { while (!signal.aborted) { let session; @@ -389,7 +405,77 @@ async function waitForSessionEndOrInterruptAsync({ ); } } finally { - process.removeListener('SIGINT', sigintHandler); + sessionInterrupt.dispose(); + } +} + +type SessionInterrupt = { + signal: AbortSignal; + abortPromise: Promise; + dispose: () => void; +}; + +function registerSessionInterrupt(deviceRunSessionId: string): SessionInterrupt { + const abortController = new AbortController(); + const { signal } = abortController; + const abortPromise = new Promise(resolve => { + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + }); + const sigintHandler = (): void => { + if (signal.aborted) { + // Force exit on a second Ctrl+C in case cleanup is hanging. The session may still be + // running on EAS, so tell the user how to make sure it gets terminated. + Log.error( + `Aborted before the simulator session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.` + ); + process.exit(130); + } + abortController.abort(); + }; + process.on('SIGINT', sigintHandler); + + return { + signal, + abortPromise, + dispose: () => process.removeListener('SIGINT', sigintHandler), + }; +} + +async function stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner, + sessionInterrupt, +}: { + graphqlClient: ExpoGraphqlClient; + deviceRunSessionId: string; + projectDir: string; + spinner: ReturnType; + sessionInterrupt: SessionInterrupt; +}): Promise { + try { + spinner.text = 'Stopping simulator session...'; + const stopped = await ensureDeviceRunSessionStoppedSafelyAsync( + graphqlClient, + deviceRunSessionId + ); + if (stopped) { + spinner.succeed('Simulator session stopped'); + await resetSimulatorEnvVerboseAsync(projectDir); + } else { + spinner.fail( + `Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.` + ); + } + } finally { + sessionInterrupt.dispose(); } } From 474342c3efed77d88fee7e8b778653671bd5f672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Wed, 29 Jul 2026 18:32:12 +0200 Subject: [PATCH 2/4] Add simulator cancellation changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e09bbb4a5..1475002553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder)) - [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic)) +- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela)) ### ๐Ÿงน Chores From bcb94d1663658c44d95bab39afd4713142231083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Thu, 30 Jul 2026 16:46:01 +0200 Subject: [PATCH 3/4] Address simulator cancellation review feedback --- .../simulator/__tests__/start.test.ts | 6 +++++- .../eas-cli/src/commands/simulator/start.ts | 9 ++++---- .../src/utils/__tests__/promise.test.ts | 16 ++++++++++++++ packages/eas-cli/src/utils/promise.ts | 21 ++++++++++++++++--- 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 packages/eas-cli/src/utils/__tests__/promise.test.ts diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index 293f9f2d61..ba44fcb5f8 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -379,6 +379,9 @@ describe(SimulatorStart, () => { }); it('stops the simulator session when interrupted before the session is ready', async () => { + const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => { + throw new Error(`process.exit(${code})`); + }); let notifyQueryStarted: () => void = () => {}; const queryStarted = new Promise(resolve => { notifyQueryStarted = resolve; @@ -400,7 +403,7 @@ describe(SimulatorStart, () => { .find(listener => !existingSigintListeners.has(listener)); expect(sigintHandler).toBeDefined(); sigintHandler?.('SIGINT'); - await commandPromise; + await expect(commandPromise).rejects.toThrow('process.exit(130)'); expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( graphqlClient, @@ -408,6 +411,7 @@ describe(SimulatorStart, () => { ); expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir); expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]); + processExitSpy.mockRestore(); }); it('prompts to select the platform when --platform is omitted', async () => { diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index ade73ac6ef..32da0ec8d5 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -217,7 +217,7 @@ export default class SimulatorStart extends EasCommand { break; } - await Promise.race([sleepAsync(POLL_INTERVAL_MS), sessionInterrupt.abortPromise]); + await sleepAsync(POLL_INTERVAL_MS, sessionInterrupt.signal); } } catch (err) { pollSpinner.fail(`Failed while polling for ${flags.type} session to be ready`); @@ -356,7 +356,7 @@ async function waitForSessionEndOrInterruptAsync({ `Simulator session active โ€” press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell` ).start(); - const { signal, abortPromise } = sessionInterrupt; + const { signal } = sessionInterrupt; try { while (!signal.aborted) { let session; @@ -366,7 +366,7 @@ async function waitForSessionEndOrInterruptAsync({ Log.debug( `Failed to poll simulator session: ${err instanceof Error ? err.message : String(err)}` ); - await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]); + await sleepAsync(POLL_INTERVAL_MS, signal); continue; } @@ -388,7 +388,7 @@ async function waitForSessionEndOrInterruptAsync({ return; } - await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]); + await sleepAsync(POLL_INTERVAL_MS, signal); } spinner.text = 'Stopping simulator session...'; @@ -477,6 +477,7 @@ async function stopDeviceRunSessionAfterInterruptAsync({ } finally { sessionInterrupt.dispose(); } + process.exit(130); } async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise { diff --git a/packages/eas-cli/src/utils/__tests__/promise.test.ts b/packages/eas-cli/src/utils/__tests__/promise.test.ts new file mode 100644 index 0000000000..bcd2548db4 --- /dev/null +++ b/packages/eas-cli/src/utils/__tests__/promise.test.ts @@ -0,0 +1,16 @@ +import { sleepAsync } from '../promise'; + +describe(sleepAsync, () => { + it('clears its timer when aborted', async () => { + jest.useFakeTimers(); + const abortController = new AbortController(); + const sleepPromise = sleepAsync(5_000, abortController.signal); + + expect(jest.getTimerCount()).toBe(1); + abortController.abort(); + await sleepPromise; + + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + }); +}); diff --git a/packages/eas-cli/src/utils/promise.ts b/packages/eas-cli/src/utils/promise.ts index 5cb6dc4bc1..cec8ddabea 100644 --- a/packages/eas-cli/src/utils/promise.ts +++ b/packages/eas-cli/src/utils/promise.ts @@ -2,8 +2,23 @@ * Returns a promise that will be resolved after given ms milliseconds. * * @param ms A number of milliseconds to sleep. - * @returns A promise that resolves after the provided number of milliseconds. + * @param signal An optional signal that resolves the sleep early when aborted. + * @returns A promise that resolves after the provided number of milliseconds or when aborted. */ -export async function sleepAsync(ms: number): Promise { - await new Promise(res => setTimeout(res, ms)); +export async function sleepAsync(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return; + } + + await new Promise(resolve => { + const onAbort = (): void => { + clearTimeout(timeoutId); + resolve(); + }; + const timeoutId = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); } From 42c0aa2fb87c8f13e9e70ec2f1bec276ef14f52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Thu, 30 Jul 2026 17:13:32 +0200 Subject: [PATCH 4/4] Clean up abortable sleep test --- packages/eas-cli/src/utils/__tests__/promise.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/utils/__tests__/promise.test.ts b/packages/eas-cli/src/utils/__tests__/promise.test.ts index bcd2548db4..042585c5f5 100644 --- a/packages/eas-cli/src/utils/__tests__/promise.test.ts +++ b/packages/eas-cli/src/utils/__tests__/promise.test.ts @@ -1,6 +1,10 @@ import { sleepAsync } from '../promise'; -describe(sleepAsync, () => { +describe('sleepAsync', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('clears its timer when aborted', async () => { jest.useFakeTimers(); const abortController = new AbortController(); @@ -11,6 +15,5 @@ describe(sleepAsync, () => { await sleepPromise; expect(jest.getTimerCount()).toBe(0); - jest.useRealTimers(); }); });