Release simulator launch pending guard on tab creation failure (#7682)

This commit is contained in:
Neil 2026-07-11 01:28:22 -07:00 committed by GitHub
parent 5da02b0105
commit fcdcab7cd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 114 additions and 35 deletions

View File

@ -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)
})
})

View File

@ -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<EmulatorAttachResult>(
{ 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<EmulatorAttachResult>(
{ 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)
}