Skip to content

Commit e3442d9

Browse files
fix(billing): resolve terminal promise even if success side effects throw
A thrown error in the success-path side-effects block (dialog close, settings show, or toast add) silently skips resolveTerminal, leaving any caller awaiting startOperation() hanging forever. poll()'s catch-all swallowed the exception, masking the hang. Adds a try/finally so resolveTerminal runs unconditionally after the success side-effects block. Adds a regression test that throws from the second mockToastAdd call and asserts the terminal promise still resolves to succeeded. Found in the review of #14421. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dc76f3e commit e3442d9

2 files changed

Lines changed: 39 additions & 18 deletions

File tree

src/platform/workspace/stores/billingOperationStore.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,25 @@ describe('billingOperationStore', () => {
660660

661661
expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
662662
})
663+
664+
it('resolves the terminal promise even if a success side effect throws', async () => {
665+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
666+
id: 'op-1',
667+
status: 'succeeded',
668+
started_at: new Date().toISOString()
669+
})
670+
mockToastAdd.mockImplementationOnce(() => {})
671+
mockToastAdd.mockImplementationOnce(() => {
672+
throw new Error('toast rendering failed')
673+
})
674+
675+
const store = useBillingOperationStore()
676+
const terminal = store.startOperation('op-1', 'topup')
677+
678+
await vi.advanceTimersByTimeAsync(0)
679+
680+
await expect(terminal).resolves.toMatchObject({ status: 'succeeded' })
681+
})
663682
})
664683

665684
describe('polling failure', () => {

src/platform/workspace/stores/billingOperationStore.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -419,26 +419,28 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
419419
return
420420
}
421421

422-
// A subscription checkout shows its own success step in the pricing dialog,
423-
// so leave it open. Top-ups have no such step: close and surface settings.
424-
if (operation.type === 'topup') {
425-
useDialogStore().closeDialog({ key: 'top-up-credits' })
426-
useSettingsDialog().show('workspace')
427-
}
422+
try {
423+
// A subscription checkout shows its own success step in the pricing dialog,
424+
// so leave it open. Top-ups have no such step: close and surface settings.
425+
if (operation.type === 'topup') {
426+
useDialogStore().closeDialog({ key: 'top-up-credits' })
427+
useSettingsDialog().show('workspace')
428+
}
428429

429-
const toastStore = useToastStore()
430-
const messageKey =
431-
operation.type === 'subscription'
432-
? 'billingOperation.subscriptionSuccess'
433-
: 'billingOperation.topupSuccess'
430+
const toastStore = useToastStore()
431+
const messageKey =
432+
operation.type === 'subscription'
433+
? 'billingOperation.subscriptionSuccess'
434+
: 'billingOperation.topupSuccess'
434435

435-
toastStore.add({
436-
severity: 'success',
437-
summary: t(messageKey),
438-
life: 5000
439-
})
440-
441-
resolveTerminal(opId)
436+
toastStore.add({
437+
severity: 'success',
438+
summary: t(messageKey),
439+
life: 5000
440+
})
441+
} finally {
442+
resolveTerminal(opId)
443+
}
442444
}
443445

444446
function handleFailure(opId: string, errorMessage: string | null) {

0 commit comments

Comments
 (0)