Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [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

## [21.4.0](https://github.com/expo/eas-cli/releases/tag/v21.4.0) - 2026-07-28
Expand Down
39 changes: 39 additions & 0 deletions packages/eas-cli/src/commands/simulator/__tests__/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void>(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
Expand Down
142 changes: 114 additions & 28 deletions packages/eas-cli/src/commands/simulator/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, {
Expand All @@ -170,6 +172,7 @@ export default class SimulatorStart extends EasCommand {
);
} catch (err) {
createSpinner.fail('Failed to create simulator session');
sessionInterrupt?.dispose();
throw err;
}

Expand All @@ -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 ||
Expand Down Expand Up @@ -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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.race picks a winner but doesn't cancel the loser. sleepAsync never exposes its timer handle, so nothing clears it.

On Ctrl+C the abort wins instantly, cleanup runs, we return - but the 5s timer is still pending, and Node won't exit while a timer is pending. I timed it: race settles at 1ms, process exits at 5005ms.

So the user sees "✔ Simulator session stopped", then up to 5s of nothing.

Fix: make sleep abortable so the timer gets cleared.

Then no race needed. Line 391 has the same issue, so one fix covers both.

}
} 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes cancel exit 0. Before the PR, Ctrl+C here hit Node's default SIGINT and exited 130. Now the handler swallows it and we return.

With --json that's a trap: no JSON on stdout, exit 0. SESSION=$(eas simulator:start --json | jq -r .id) gets an empty id and thinks it worked.

Can we process.exit(130) after cleanup? Exiting 0 is fine once the session is live — Ctrl+C is the point there. But here we never delivered anything.

}

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)}`
);
Expand All @@ -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,
Expand All @@ -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}`
);
Expand All @@ -257,6 +293,7 @@ export default class SimulatorStart extends EasCommand {
deviceRunSessionId,
deviceRunSessionUrl,
projectDir,
sessionInterrupt,
});
}
}
Expand Down Expand Up @@ -307,40 +344,19 @@ async function waitForSessionEndOrInterruptAsync({
deviceRunSessionId,
deviceRunSessionUrl,
projectDir,
sessionInterrupt,
}: {
graphqlClient: ExpoGraphqlClient;
deviceRunSessionId: string;
deviceRunSessionUrl: string;
projectDir: string;
sessionInterrupt: SessionInterrupt;
}): Promise<void> {
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<void>(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;
Expand Down Expand Up @@ -389,7 +405,77 @@ async function waitForSessionEndOrInterruptAsync({
);
}
} finally {
process.removeListener('SIGINT', sigintHandler);
sessionInterrupt.dispose();
}
}

type SessionInterrupt = {
signal: AbortSignal;
abortPromise: Promise<void>;
dispose: () => void;
};

function registerSessionInterrupt(deviceRunSessionId: string): SessionInterrupt {
const abortController = new AbortController();
const { signal } = abortController;
const abortPromise = new Promise<void>(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<typeof ora>;
sessionInterrupt: SessionInterrupt;
}): Promise<void> {
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();
}
}

Expand Down
Loading