fix(accounts): free the queue for a switch, not only for another add

Switching or removing an account shares the mutation queue an abandoned
sign-in was holding, so the commonest thing a user does after giving up
— pick a different account — still spun for the whole deadline while Add
recovered instantly. Both now supersede, as does the Claude side.

Every caller is a person: the two IPC handlers and the mobile RPC
methods. No poll, sync or CLI path reaches them, and a sign-in that
already wrote credentials refuses the cancel, so a switch cannot discard
one that succeeded.

Also from review: the Cancel button regains the gap its Claude twin has
(layout is allowed by the design-system rule; only the colour override
was not), and the URL subscription says what it is — registration for
the process's lifetime, with no teardown to hand back.
This commit is contained in:
Neil
2026-09-17 22:57:54 -07:00
parent e5daad4431
commit d2ce6d8db8
5 changed files with 30 additions and 8 deletions
+5 -2
View File
@@ -93,10 +93,12 @@ export class ClaudeAccountService {
}
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -104,6 +106,7 @@ export class ClaudeAccountService {
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
@@ -112,8 +115,8 @@ export class ClaudeAccountService {
}
// Why before the queue, not inside it: the abandoned login owns the queue slot
// the next add is waiting for. Only add/reauthenticate open a browser, so only
// they supersede — never serializeMutation, which background work also uses.
// every later account action waits for. Called from the four the user drives,
// never from serializeMutation, which background work also uses.
private supersedePendingLogin(): void {
if (this.cancelPendingLogin()) {
console.info(
@@ -58,9 +58,10 @@ function createStubLoginChild(): StubLoginChild {
async function createServiceWithHangingLogin(): Promise<{
service: {
addAccount: () => Promise<{ accounts: { email: string }[] }>
selectAccount: (accountId: string | null) => Promise<unknown>
cancelPendingLogin: () => boolean
getPendingLoginUrl: () => string | null
subscribePendingLoginUrl: (listener: (url: string | null) => void) => void
onPendingLoginUrlChanged: (listener: (url: string | null) => void) => void
}
children: StubLoginChild[]
/** The `CODEX_HOME` each login was spawned against. */
@@ -119,6 +120,19 @@ describe('CodexAccountService abandoned login', () => {
await retryRejection
})
it('frees the queue for a plain account switch too, not only for another add', async () => {
const { service, children } = await createServiceWithHangingLogin()
const abandoned = service.addAccount()
const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
await vi.waitUntil(() => children.length === 1)
// Why: switching to the system default is the commonest thing a user does
// after giving up on a sign-in, and it shares the add's mutation queue.
await service.selectAccount(null)
await abandonedRejection
expect(children[0].kill).toHaveBeenCalled()
})
it('refuses to cancel a sign-in that already wrote credentials, and keeps the account', async () => {
const { service, children, loginHomes } = await createServiceWithHangingLogin()
const pending = service.addAccount()
@@ -156,7 +170,7 @@ describe('CodexAccountService abandoned login', () => {
it('publishes the sign-in link codex prints and drops it when the login ends', async () => {
const { service, children } = await createServiceWithHangingLogin()
const published: (string | null)[] = []
service.subscribePendingLoginUrl((url) => published.push(url))
service.onPendingLoginUrlChanged((url) => published.push(url))
const pending = service.addAccount()
const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
+7 -3
View File
@@ -178,7 +178,8 @@ export class CodexAccountService {
return this.pendingLoginUrl
}
subscribePendingLoginUrl(listener: (url: string | null) => void): void {
/** Registration lasts the process's lifetime; there is no teardown to hand back. */
onPendingLoginUrlChanged(listener: (url: string | null) => void): void {
this.pendingLoginUrlListeners.add(listener)
}
@@ -195,8 +196,8 @@ export class CodexAccountService {
}
// Why before the queue, not inside it: the abandoned login owns the queue slot
// the next add is waiting for. Only add/reauthenticate open a browser, so only
// they supersede — never serializeMutation, which background work also uses.
// every later account action waits for. Called from the four the user drives,
// never from serializeMutation, which background reset-credit work also uses.
private supersedePendingLogin(): void {
if (this.cancelPendingLogin()) {
console.info('[codex-accounts] Cancelled a pending Codex login superseded by a new request.')
@@ -225,10 +226,12 @@ export class CodexAccountService {
}
async removeAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.remove(accountId))
}
async selectAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId))
}
@@ -236,6 +239,7 @@ export class CodexAccountService {
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
this.supersedePendingLogin()
return this.serializeMutation(() => this.selection.select(accountId, target))
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function registerCodexAccountHandlers(
ipcMain.handle('codexAccounts:pendingLoginUrl', () => codexAccounts.getPendingLoginUrl())
// Why: Settings can open after the login already printed its link, so the
// renderer reads the current value on mount and this only carries changes.
codexAccounts.subscribePendingLoginUrl(broadcastCodexPendingLoginUrl)
codexAccounts.onPendingLoginUrlChanged(broadcastCodexPendingLoginUrl)
ipcMain.handle(
'codexAccounts:reauthenticate',
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
@@ -187,6 +187,7 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea
variant="ghost"
size="xs"
onClick={() => void window.api.codexAccounts.cancelPendingLogin()}
className="gap-1.5"
>
<X />
{translate('auto.components.settings.AccountsPane.dbb9626ed1', 'Cancel')}