-
Notifications
You must be signed in to change notification settings - Fork 222
[eas-cli] Stop simulator job when start is canceled #4113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)}` | ||
| ); | ||
|
|
@@ -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<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; | ||
|
|
@@ -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(); | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.