From fcdcab7cd4fbd1852ce64adeaa974b690f6ecc1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:28:22 -0700 Subject: [PATCH] Release simulator launch pending guard on tab creation failure (#7682) --- .../src/lib/open-mobile-emulator-tab.test.ts | 70 ++++++++++++++++ .../src/lib/open-mobile-emulator-tab.ts | 79 +++++++++++-------- 2 files changed, 114 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index 6afb783d2..100e0532e 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -194,4 +194,74 @@ describe('openMobileEmulatorTab', () => { expect(callRuntimeRpc).not.toHaveBeenCalled() expect(ensureSimulatorTab).not.toHaveBeenCalled() }) + + it('clears manual launch pending state when the simulator tab cannot be created', async () => { + vi.mocked(ensureSimulatorTab).mockReturnValue(null) + + await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBeNull() + + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(isManualSimulatorLaunchPending('wt-1')).toBe(false) + }) + + it('clears manual launch pending state when simulator tab creation throws', async () => { + vi.mocked(ensureSimulatorTab).mockImplementation(() => { + throw new Error('split group missing') + }) + + await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).rejects.toThrow( + 'split group missing' + ) + + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(isManualSimulatorLaunchPending('wt-1')).toBe(false) + }) + + it('allows a successful retry after simulator tab creation returns null', async () => { + vi.mocked(ensureSimulatorTab).mockReturnValueOnce(null).mockReturnValueOnce('sim-retry') + vi.mocked(callRuntimeRpc).mockResolvedValue(mockAttachResult) + + await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBeNull() + await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe( + 'sim-retry' + ) + + expect(ensureSimulatorTab).toHaveBeenCalledTimes(2) + const attachCalls = vi.mocked(callRuntimeRpc).mock.calls.filter(([, method]) => + method === 'emulator.attach' + ) + expect(attachCalls).toHaveLength(1) + expect(isManualSimulatorLaunchPending('wt-1')).toBe(false) + }) + + it('does not release another in-flight launch when duplicate tab creation throws', async () => { + let resolveAttach: (value: typeof mockAttachResult) => void = () => {} + vi.mocked(callRuntimeRpc).mockImplementation( + () => + new Promise((resolve) => { + resolveAttach = resolve + }) + ) + vi.mocked(ensureSimulatorTab) + .mockImplementationOnce(() => { + mockStoreState.unifiedTabsByWorktree['wt-1'] = [{ + id: 'sim-1', + contentType: 'simulator' + }] + return 'sim-1' + }) + .mockImplementationOnce(() => { + throw new Error('split group missing') + }) + + const firstLaunch = openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' }) + await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).rejects.toThrow( + 'split group missing' + ) + + expect(isManualSimulatorLaunchPending('wt-1')).toBe(true) + resolveAttach(mockAttachResult) + await firstLaunch + expect(isManualSimulatorLaunchPending('wt-1')).toBe(false) + }) }) diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.ts b/src/renderer/src/lib/open-mobile-emulator-tab.ts index d48d1b226..45f565e04 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.ts @@ -72,46 +72,55 @@ export async function openMobileEmulatorTab( cancelPendingSimulatorPaneShutdown(worktreeId) const alreadyLaunching = isManualSimulatorLaunchPending(worktreeId) - if (!alreadyLaunching) { - beginManualSimulatorLaunch(worktreeId) + if (alreadyLaunching) { + return ensureSimulatorTab(worktreeId, { + placement: options.placement ?? 'rightSplit', + targetGroupId, + surfacePane: true + }) } - const tabId = ensureSimulatorTab(worktreeId, { - placement: options.placement ?? 'rightSplit', - targetGroupId, - surfacePane: true - }) - if (!tabId || alreadyLaunching) { - return tabId - } - dispatchManualSimulatorLaunchStarted(worktreeId) + beginManualSimulatorLaunch(worktreeId) try { - // Why: the pane is visible but inert while serve-sim settles; the actual - // stream is handed to it only after attach returns ready info. - const result = await callRuntimeRpc( - { kind: 'local' }, - 'emulator.attach', - { - worktree: worktreeId, - focus: false - } - ) - if (!result.attached || !result.info) { - throw new Error('Could not start the emulator.') + // Why: this scope owns the guard from before tab creation through attach; + // every early return or error must release it so a retry can proceed. + const tabId = ensureSimulatorTab(worktreeId, { + placement: options.placement ?? 'rightSplit', + targetGroupId, + surfacePane: true + }) + if (!tabId) { + return null } - // Why: users can close the tab while serve-sim is still starting; after - // attach registers the managed session, clean it up if no pane remains. - if (await shutdownManagedSimulatorIfNoPane(worktreeId, tabId)) { + dispatchManualSimulatorLaunchStarted(worktreeId) + try { + // Why: the pane is visible but inert while serve-sim settles; the actual + // stream is handed to it only after attach returns ready info. + const result = await callRuntimeRpc( + { kind: 'local' }, + 'emulator.attach', + { + worktree: worktreeId, + focus: false + } + ) + if (!result.attached || !result.info) { + throw new Error('Could not start the emulator.') + } + // Why: users can close the tab while serve-sim is still starting; after + // attach registers the managed session, clean it up if no pane remains. + if (await shutdownManagedSimulatorIfNoPane(worktreeId, tabId)) { + return tabId + } + + rememberPrelaunchedSimulatorSession(worktreeId, result.info) + dispatchPrelaunchedSession(worktreeId, result.info) + return tabId + } catch (error) { + const message = getLaunchErrorMessage(error) + toast.error(message) + dispatchManualSimulatorLaunchFailed(worktreeId, message) return tabId } - - rememberPrelaunchedSimulatorSession(worktreeId, result.info) - dispatchPrelaunchedSession(worktreeId, result.info) - return tabId - } catch (error) { - const message = getLaunchErrorMessage(error) - toast.error(message) - dispatchManualSimulatorLaunchFailed(worktreeId, message) - return tabId } finally { finishManualSimulatorLaunch(worktreeId) }