From 94ab208fd1b8d7bc3ded72ba76738626df28579c Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Fri, 13 Nov 2026 17:25:03 +0100 Subject: [PATCH] [eas-cli] Clear the simulator session file when start fails before ready When the session errors or times out before it is ready and the stop is confirmed, reset .env.eas-simulator so simulator:exec does not report the session as still starting. Log the saved path only after the interrupt check, and shorten the changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-exec-require-session.md | 2 +- .../simulator/__tests__/index.test.ts | 29 ++++++++++++++++ .../eas-cli/src/commands/simulator/exec.ts | 3 +- .../eas-cli/src/commands/simulator/index.ts | 34 ++++++++++++++++--- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.changeset/simulator-exec-require-session.md b/.changeset/simulator-exec-require-session.md index fe3450f26..d426aa2fc 100644 --- a/.changeset/simulator-exec-require-session.md +++ b/.changeset/simulator-exec-require-session.md @@ -2,4 +2,4 @@ 'eas-cli': patch --- -Make `eas simulator:exec` fail, instead of running the command against a local device, when `.env.eas-simulator` has no session or the session is still starting; sessions started with `--out-config-type env` write no file, so run their commands without `eas simulator:exec`. Print the full path of `.env.eas-simulator` in `eas simulator:start`. +Make `eas simulator:exec` fail when `.env.eas-simulator` has no ready session, instead of running the command against a local device. diff --git a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts index ab319f9e6..95bdc9b5f 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -2834,6 +2834,34 @@ describe(Simulator, () => { await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/); expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + // The file must not keep saying that the session is starting. + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + }); + + it('keeps the dotenv file when it cannot confirm that a session which errored before it was ready stopped', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('network failed')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/); + expect(mockResetSimulatorEnvAsync).not.toHaveBeenCalled(); + }); + + it('keeps the original error when it cannot clean up the dotenv file of a session that errored before it was ready', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + mockResetSimulatorEnvAsync.mockRejectedValue(new Error('read-only file system')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/); + expect(Log.warn).toHaveBeenCalledWith( + `The session stopped, but ${simulatorDotenvPath} could not be cleaned up: read-only file system` + ); }); it('throws when the turtle job run finishes before the session is ready', async () => { @@ -3172,6 +3200,7 @@ describe(Simulator, () => { await expect(command.runAsync()).rejects.toThrow(/Timed out after \d+s waiting for/); expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); }); it('prints JSON only and skips the human-readable output with --json', async () => { diff --git a/packages/eas-cli/src/commands/simulator/exec.ts b/packages/eas-cli/src/commands/simulator/exec.ts index 9927290b3..d02a8162c 100644 --- a/packages/eas-cli/src/commands/simulator/exec.ts +++ b/packages/eas-cli/src/commands/simulator/exec.ts @@ -55,7 +55,8 @@ export default class SimulatorExec extends EasCommand { if (simulatorEnv[EAS_SIMULATOR_SESSION_STATUS] === EAS_SIMULATOR_SESSION_STATUS_STARTING) { throw new Error( `Simulator session ${sessionId} in ${simulatorEnvFilePath} is still starting. ` + - 'Wait until `eas simulator:start` reports that the session is ready, then run this command again.' + 'Wait until `eas simulator:start` reports that the session is ready, then run this command again. ' + + 'If `eas simulator:start` is no longer running, start a new session with `eas simulator:start`.' ); } diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 40596bc4a..b9c3ea45e 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -582,7 +582,9 @@ With local egress, actions wait for the connection and require interactive mode. } } catch (err) { pollSpinner.fail(`Failed while polling for ${flags.type} session to be ready`); - await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId); + if (await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId)) { + await resetUnreadySimulatorEnvAsync(projectDir, deviceRunSessionId); + } sessionInterrupt.dispose(); throw err; } @@ -600,7 +602,9 @@ With local egress, actions wait for the connection and require interactive mode. if (!remoteConfig) { pollSpinner.fail(`Timed out waiting for ${flags.type} session to start`); - await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId); + if (await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId)) { + await resetUnreadySimulatorEnvAsync(projectDir, deviceRunSessionId); + } sessionInterrupt.dispose(); throw new Error( `Timed out after ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to start (excluding time in the queue). ${link(deviceRunSessionUrl)}` @@ -835,9 +839,6 @@ With local egress, actions wait for the connection and require interactive mode. if (jsonFlag || imported.config?.actions?.length) { // Keep the previous session's saved credentials until setup succeeds. simulatorEnvWritten = await writeReadySimulatorEnvAsync(); - if (simulatorEnvWritten && !jsonFlag) { - Log.log(`Saved the session configuration to ${getSimulatorEnvFilePath(projectDir)}`); - } if (sessionInterrupt.signal.aborted) { await stopDeviceRunSessionAfterInterruptAsync({ graphqlClient, @@ -848,6 +849,9 @@ With local egress, actions wait for the connection and require interactive mode. }); return; } + if (simulatorEnvWritten && !jsonFlag) { + Log.log(`Saved the session configuration to ${getSimulatorEnvFilePath(projectDir)}`); + } } if (jsonFlag) { sessionInterrupt.dispose(); @@ -1118,6 +1122,26 @@ async function stopDeviceRunSessionCreatedAfterCancelAsync( } } +/** + * Clears the dotenv file of a session that stopped before it was ready, so that + * `eas simulator:exec` does not report the session as still starting. A failure only warns, so + * that the caller can still throw the error that stopped the session. + */ +async function resetUnreadySimulatorEnvAsync( + projectDir: string, + deviceRunSessionId: string +): Promise { + try { + await resetSimulatorEnvAsync(projectDir, deviceRunSessionId); + } catch (error) { + Log.warn( + `The session stopped, but ${getSimulatorEnvFilePath(projectDir)} could not be cleaned up: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + async function resetSimulatorEnvVerboseAsync( projectDir: string, deviceRunSessionId: string