fix(usage): stop usage.logins timing out with many profiles under load

TS
2h ago
+433-84

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.logins gets .meta({ timeoutMs: 15_000 }), the budget usage.quota already has, and a row in QUERY_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 mapWithConcurrency helper, 4 at a time, instead of starting every profile's Keychain reads at once. The helper stays in git/utils/git-helpers.ts; its comment already offers it to other callers, and workers/tasks/git.ts imports it from there too.
  • Scans cut short by the discovery budget. discoverClaudeProfiles stops 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, findCompletedLogin reported a sign-in that nobody made, and the dialog then ran usage.prepareAccount on that existing dir without a click (point 4 of the triage on #9171). Now:
    • usage.logins says when Claude discovery stopped at its budget, in a new output field, claudePartial. It comes from scanClaudeProfiles; discoverClaudeProfiles keeps its signature for its other callers.
    • findCompletedLogin does 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. useHostUsageLogins retries one TIMEOUT (loginsQueryRetry) before the dialog shows an error. Other errors keep hostServiceQueryRetry. The retry joins the scan that the timed-out poll left running, so it does not add work on the host.
  • Docs. The queryProcedure comment and QUERY_TIMEOUTS.md said the renderer retries TIMEOUT errors. Only the WorkspaceClientProvider client 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.logins has a 15 s budget.
    • scanLogins reports claudePartial from 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 most FINGERPRINT_READ_CONCURRENCY reads 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, scanClaudeProfiles returns only that dir and partial: true. With a clock that stays at 0, it returns every dir and partial: 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. nextBaseline swaps a partial baseline for the first complete poll and keeps every other baseline.
  • apps/desktop/.../useHostUsageLogins/useHostUsageLogins.test.ts (new): loginsQueryRetry retries the first TIMEOUT and not the second, gives the same answer as hostServiceQueryRetry for 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 main at 170e1f4ad): logins.test.ts fails with Cannot find module './logins', and useHostUsageLogins.test.ts fails with Export named 'loginsQueryRetry' not found.

  • Red (commit 3): the three changed test files fail on the missing exports scanClaudeProfiles, scanLogins and nextBaseline. With the nextBaseline import and block taken out, the old findCompletedLogin fails "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.ts and logins.test.ts, 17 pass. findCompletedLogin.test.ts and useHostUsageLogins.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 to profiles.length, replacing ??= with = in shareInFlight, and setting the TIMEOUT retries to 0;
    • a scanClaudeProfiles that is always or never partial, has no budget check, or reads the real clock instead of the one passed in;
    • claudePartial set to a fixed true or false;
    • the new check in findCompletedLogin removed, or without its !before, !commandSucceeded or agent === "claude" part;
    • three wrong nextBaseline bodies.

    One mutation passes the tests: the dialog no longer calling nextBaseline. No test renders the dialog.

  • bun run typecheck: all 42 turbo tasks pass.

  • Lint: biome check on the whole repo (what bun run lint runs) checks 12,105 files and reports no diagnostics. I ran the installed Biome 2.4.2 binary directly, because bunx @biomejs/biome@2.4.2 fetches the package manifest and the container had no network.

  • bun run test in packages/host-service: 2704 pass, 2 skip, 3 fail. In apps/desktop: 9026 pass, 1 skip, 8 fail. The same 3 and 8 tests fail on upstream main in the same container. They are terminal tests that need real PTYs, the bundled pty-daemon and ps, 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 lint and bun run typecheck pass (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 TIMEOUT error before showing an error; that retry joins the scan the timed-out poll left running.
  • scanClaudeProfiles now reports when it stopped at its time budget; usage.logins passes that on as a new claudePartial field, and findCompletedLogin no 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.md and the queryProcedure comment, which claimed the renderer retries TIMEOUT errors; only WorkspaceClientProvider does.

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.