fix(usage): stop usage.logins timing out with many profiles under load
What & why
Fixes #9171.
The Add account and Switch sign-in dialogs poll usage.logins every 3 s while a sign-in is pending. Each poll reads the Keychain for every discovered Claude profile, which runs security find-generic-password 8 to 12 times per profile, one call after another. The procedure had the 5 s default budget. With a few dozen profiles on a busy Mac, polls timed out with "usage.logins timed out after 5000ms". When the first poll timed out, Sign in stayed disabled, because the dialog uses the first result as its baseline.
- Budget.
usage.loginsgets.meta({ timeoutMs: 15_000 }), the budgetusage.quotaalready has, and a row inQUERY_TIMEOUTS.md. - One scan at a time. The scan moves to
usage/logins.ts, and callers share the scan that is in flight (shareInFlight). The timeout middleware does not stop a scan, so before this change the poll after a timeout started a second full scan next to the first. This is not a result cache: the first call after a scan settles starts a new one. A call that arrives while a scan runs gets that scan's result, which can be up to one scan older than the call. - Fewer reads at once. Profile fingerprints go through the existing
mapWithConcurrencyhelper, 4 at a time, instead of starting every profile's Keychain reads at once. The helper stays ingit/utils/git-helpers.ts; its comment already offers it to other callers, andworkers/tasks/git.tsimports it from there too. - Scans cut short by the discovery budget.
discoverClaudeProfilesstops after 1.5 s, so under load a poll can leave out profiles that exist. Before this change most such polls timed out; with the 15 s budget they reach the dialog. If the dialog's baseline left out its target profile and a later poll had it,findCompletedLoginreported a sign-in that nobody made, and the dialog then ranusage.prepareAccounton that existing dir without a click (point 4 of the triage on #9171). Now:usage.loginssays when Claude discovery stopped at its budget, in a new output field,claudePartial. It comes fromscanClaudeProfiles;discoverClaudeProfileskeeps its signature for its other callers.findCompletedLogindoes not take a Claude profile that a partial baseline left out as signed in. A login command that succeeded in the dialog's own terminal still counts, and so does a new fingerprint on a profile that the baseline has.- The dialog swaps a partial baseline for the first complete poll that shows no sign-in (
nextBaseline), so a sign-in after that poll is still seen. If every poll stops at the budget, a sign-in run outside the dialog's terminal (for example on a remote host) into a profile that the baseline left out is not seen: the dialog keeps waiting instead of guessing.
- One retry in the dialog.
useHostUsageLoginsretries oneTIMEOUT(loginsQueryRetry) before the dialog shows an error. Other errors keephostServiceQueryRetry. The retry joins the scan that the timed-out poll left running, so it does not add work on the host. - Docs. The
queryProcedurecomment andQUERY_TIMEOUTS.mdsaid the renderer retriesTIMEOUTerrors. Only theWorkspaceClientProviderclient does. Both now say which client retries.
Compatibility: one new output field, claudePartial on usage.logins. No input, procedure type or error code changes. The released desktops I checked, from 1.29.0 (the minimum) to 2.0.2, call usage.logins and read only the fields they know. This desktop reads a missing claudePartial (an older host) as a complete scan, which is how it read every scan before. An older desktop calling this host gets fewer timeouts. This desktop calling an older host retries one 5 s timeout, and that host then starts one more scan.
Not changed here, from the triage comment on #9171: the dialog still shows a poll error when it has data from an earlier poll. If it hid that error, a dialog whose polls keep failing would wait without telling the user why.
How I tested it
I wrote the tests first and committed them alone, then added the fix: commits 1 and 3 add only tests, and commits 2 and 4 add the fixes.
packages/host-service/src/trpc/router/usage/logins.test.ts(new):usage.loginshas a 15 s budget.scanLoginsreportsclaudePartialfrom the Claude profile scan. Its readers are passed in, so the test reads no real home dir or Keychain.shareInFlight: a call made while a scan runs gets that scan's result; a call after it settles starts a new scan; so does a call after a failed scan.readClaudeFingerprints: with a fake reader that holds each read open, at mostFINGERPRINT_READ_CONCURRENCYreads are in flight, results keep input order when reads finish out of order, and API-billed profiles are not read. No real Keychain.
packages/host-service/src/trpc/router/usage/profiles.test.ts: with a fake clock that passes the budget after the first dir,scanClaudeProfilesreturns only that dir andpartial: true. With a clock that stays at 0, it returns every dir andpartial: false.apps/desktop/.../findCompletedLogin/findCompletedLogin.test.ts: a Claude profile that a partial baseline left out is not taken as signed in. A re-login of a profile that the partial baseline has, a finished CLI login and a Codex sign-in still are.nextBaselineswaps a partial baseline for the first complete poll and keeps every other baseline.apps/desktop/.../useHostUsageLogins/useHostUsageLogins.test.ts(new):loginsQueryRetryretries the firstTIMEOUTand not the second, gives the same answer ashostServiceQueryRetryfor connection errors, and does not retry other codes.
All checks ran in Docker on oven/bun:1.4.3 (the version in .bun-version) after bun install --frozen --ignore-scripts, with no network after the install:
-
Red (commit 1, the first tests on upstream
mainat 170e1f4ad):logins.test.tsfails withCannot find module './logins', anduseHostUsageLogins.test.tsfails withExport named 'loginsQueryRetry' not found. -
Red (commit 3): the three changed test files fail on the missing exports
scanClaudeProfiles,scanLoginsandnextBaseline. With thenextBaselineimport and block taken out, the oldfindCompletedLoginfails "does not take a profile a partial baseline left out as signed in": it returns{ selection: "/home/test/.claude-work", ... }, a sign-in that nobody made. -
Green:
profiles.test.tsandlogins.test.ts, 17 pass.findCompletedLogin.test.tsanduseHostUsageLogins.test.ts, 19 pass. All usage router tests (159 in 30 files) and desktop usage settings tests (54 in 9 files) pass. -
Mutations. Each of these makes a test fail:
- removing the
logins.meta, raising the read limit toprofiles.length, replacing??=with=inshareInFlight, and setting theTIMEOUTretries to 0; - a
scanClaudeProfilesthat is always or never partial, has no budget check, or reads the real clock instead of the one passed in; claudePartialset to a fixedtrueorfalse;- the new check in
findCompletedLoginremoved, or without its!before,!commandSucceededoragent === "claude"part; - three wrong
nextBaselinebodies.
One mutation passes the tests: the dialog no longer calling
nextBaseline. No test renders the dialog. - removing the
-
bun run typecheck: all 42 turbo tasks pass. -
Lint:
biome checkon the whole repo (whatbun run lintruns) checks 12,105 files and reports no diagnostics. I ran the installed Biome 2.4.2 binary directly, becausebunx @biomejs/biome@2.4.2fetches the package manifest and the container had no network. -
bun run testinpackages/host-service: 2704 pass, 2 skip, 3 fail. Inapps/desktop: 9026 pass, 1 skip, 8 fail. The same 3 and 8 tests fail on upstreammainin the same container. They are terminal tests that need real PTYs, the bundled pty-daemon andps, which the container does not have. No test fails only with this change.
No screenshot: the only visible change is that the timeout error stops showing, and reproducing it needs a few dozen Keychain-backed profiles on a loaded Mac. The cut-short scan also needs discovery to pass its 1.5 s budget, so the tests above cover it with a fake clock. To check what the limit of 4 costs, I timed security find-generic-password lookups that find nothing on a 16-core Mac at a load average of about 85. For 30 profiles with 8 lookups each, they took 0.85 s with no limit, 1.5 s with 8 in flight and 2.1 s with 4 in flight.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (CI fails on lint warnings too) - "Allow edits from maintainers" is checked on fork PRs
- I have signed the CLA (the bot asks on your first PR)
🤖 Generated with Claude Code
Summary by cubic
Fixes usage.logins timing out with many Claude profiles on a loaded machine, which kept Sign in disabled in the Add account and Switch sign-in dialogs, and stops a cut-short profile scan from being mistaken for a sign-in.
- Raises the procedure's timeout to 15s, matching
usage.quota. - Callers share one in-flight scan, so a timed-out poll no longer has the next poll start a second full scan beside it.
- Limits concurrent Keychain fingerprint reads to 4 profiles at a time.
- The dialog retries one
TIMEOUTerror before showing an error; that retry joins the scan the timed-out poll left running. scanClaudeProfilesnow reports when it stopped at its time budget;usage.loginspasses that on as a newclaudePartialfield, andfindCompletedLoginno longer counts a profile a partial baseline left out as signed in unless the dialog's login command succeeded. The dialog swaps the partial baseline for the first complete poll.- Corrects
QUERY_TIMEOUTS.mdand thequeryProcedurecomment, which claimed the renderer retriesTIMEOUTerrors; onlyWorkspaceClientProviderdoes.
No input, procedure type, or error code changes. usage.logins gains one output field, claudePartial; older desktops ignore it, and this desktop reads a missing field on an older host as a complete scan. An older desktop calling this host sees fewer timeouts; this desktop calling an older host retries one 5s timeout.
Summary by CodeRabbit
- Bug Fixes
- Improved reliability when loading login information in usage settings. Timed-out scans can be retried, and retry behavior better handles temporary service and connection issues.
- Claude and Codex login details are gathered more consistently, including when scans overlap or only some profiles are found before a scan ends.
- Partial Claude scans are less likely to trigger a false new-login notification, while completed scans can update the comparison baseline.
- Improved handling of login fingerprints, including when credential files cannot be read.