fix: stop reporting a healthy token as expired when refresh is deferred - #273
Open
Toady00 wants to merge 3 commits into
Open
fix: stop reporting a healthy token as expired when refresh is deferred#273Toady00 wants to merge 3 commits into
Toady00 wants to merge 3 commits into
Conversation
refreshIfNeeded() returned null for two conditions that are deferrals rather than credential failures: an armed rate-limit cooldown, and a sibling process holding the cross-process refresh lock without publishing a token inside the 5s adopt window. null is this module's signal that no usable credentials exist, so the proactive sync timer in index.ts turned both into the user-facing "Proactive token refresh failed. Run `claude` to re-authenticate." That timer asks an hour ahead of expiry, so the token in question is normally still healthy — and with several OpenCode instances sharing one credential, every rotation sends all but the lock winner down these branches. Running `claude` fixes nothing, because nothing broke. performRefresh already guards this exact case twice, on the transient path and before the CLI fallback: hand back credentials that still have more than CLI_FALLBACK_THRESHOLD_MS of life instead of null. Only the two deferral branches were missing it. Apply it there via a shared deferToUsableCredentials() helper, which also logs a refresh_deferred_still_usable diagnostic. The reactive path is unchanged: with the default 60s threshold these branches are only reached once the token has under 60s left, where the guard is false and null still stands, so getCredentialsWithBackoff goes on to wait the cooldown out. A null from the proactive timer now means what the warning claims it means. Closes griffinmartin#272
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
The previous commit stopped deferrals from reporting a healthy token as dead, but gated it on the token still having more than 60s of life. That leaves the case users actually hit. macOS coalesces timers on a locked machine, so the proactive window can pass without the 5-minute timer firing at all. Every instance then converges at expiry: the winner takes the lock and may sit inside a CLI fallback for up to 120s, while the losers give up waiting after 5s holding an already-expired token. Being below the reactive window, they had nothing usable to hand back, fell through to a bare null, and told the user to re-authenticate — while a sibling was actively completing the refresh that fixed it seconds later. The remaining-lifetime of the token was never the right question. A deferral means another refresher owns the work right now; nothing is known to be wrong with the credentials either way. Record the deferral and expose it as wasRefreshDeferred(), so the sync timer can tell "a sibling is refreshing" apart from "this token is dead" instead of inferring it from a null. The marker resets at the top of every refreshIfNeeded call, so it can only ever describe the call that just ran and cannot suppress a later, genuine failure. Still-usable credentials are returned exactly as before; this only changes what the caller is told when there is nothing left to return. The warned latch is untouched on a deferral, so a real outage still reports once. Refs griffinmartin#272
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
this need to be merged |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
refreshIfNeeded()returnednullfor two conditions that are deferrals, not credential failures:src/credentials.ts:513-524— an armed rate-limit cooldownsrc/credentials.ts:539-549— a sibling process holds the cross-process refresh lock and publishes nothing inside the 5swaitForAdoptbudgetnullis this module's signal that no usable credentials exist, so the proactive sync timer atsrc/index.ts:236-255turned both into:That timer asks an hour ahead of expiry, so the token those branches are asked about is normally still healthy. And because every OpenCode process runs its own timer against one shared credential and one advisory lock file, each token rotation is a race that exactly one process wins — every loser takes the lock-busy branch and prints the warning. Running
claudefixes nothing, because nothing broke.performRefreshalready guards this exact case, twice:src/credentials.ts:692— the transient pathsrc/credentials.ts:722— before the CLI fallbackBoth do
if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) return creds. Only the two deferral branches were missing it. This PR applies the same guard to both, through a shareddeferToUsableCredentials()helper that also emits arefresh_deferred_still_usablediagnostic.Related issue
Closes #272
Behavior change
Scoped entirely to the proactive path. The reactive path uses the default 60s threshold, so it only reaches these branches once the token has under 60s left — where the guard is false and
nullstill stands, andgetCredentialsWithBackoffgoes on to wait the cooldown out as before.null→ re-authenticate warningnull→ re-authenticate warningnullnull(unchanged)getCachedCredentials(60s)nullnull(unchanged)A side effect worth stating: a
nullfrom the proactive timer now means what the warning claims it means — under 60s of life and unable to refresh. That warning was previously unactionable noise; it is now a real signal.Testing
make allpasses: lint clean (4 pre-existing warnings insrc/keychain.test.ts, untouched), build clean, 332 → 340 tests passing.Five tests added, each verified to fail against the unpatched code first, for the right reason:
keeps serving still-usable credentials while a refresh cooldown is active— failed withundefinedvs"existing-token"keeps serving still-usable credentials when the lock holder publishes nothing— failed withundefinedvs"existing-token"proactive refresh timer stays quiet when a refresh is merely deferred(end-to-end throughindex.ts, asserts the toast is not emitted) — failed with the warning presentstill returns null during a cooldown when credentials are past the reactive window— passes before and after; pins the reactive path so the fix cannot start handing back expired tokensstill returns null on a busy lock when credentials are past the reactive window— same, for the lock branchManual verification against real keychain credentials, using a read-only harness (no OAuth request, no keychain write, no
claudespawn, lock directory redirected so live OpenCode instances were unaffected):2.1.6dist, token with 470 minutes of life left, sibling holding the lock →refreshIfNeededreturnednull. Debug log ends atrefresh_lock_busyplus 5s of adopt polling: norefresh_failed, norefresh_terminal, norefresh_exhausted. Nothing failed.{"event":"refresh_lock_busy","source":"Claude Code-credentials"} {"event":"refresh_deferred_still_usable","source":"Claude Code-credentials","reason":"lock_busy","expiresIn":28172303}Update: second commit, the case that actually bites
Field testing found a more common trigger the first commit does not cover.
The trigger
The warning correlates with the machine being locked with the screensaver running — not sleeping.
macOS applies timer coalescing / App Nap to background processes in that state, so the 5-minute
setIntervalinsrc/index.tsdoes not fire on schedule. The proactive window can pass entirely without a single tick. Every instance then converges at once when the machine becomes active again, by which point the token is at or past expiry.Measured on a machine with 3 concurrent OpenCode instances:
16:00:21, so the proactive window opened at15:00:21mdatshows the credential was rotated at20260816200021Z=16:00:21local — exactly at expiry, not during the hour-long windowIt is not the OAuth path
Ruled out by running a real refresh against the live endpoint with the same credentials:
{"event":"refresh_lock_acquired","source":"Claude Code-credentials"} {"event":"refresh_needed","expiresIn":22696498} {"event":"refresh_started","source":"oauth"} {"event":"refresh_success","source":"oauth"} {"event":"writeback_success","source":"Claude Code-credentials"}370ms end to end, clean rotation and writeback. The refresh path is healthy; it simply never gets called while the machine is locked.
Why the first fix was not enough
The herd converging at expiry is the worst case for the lock:
DEFAULT_LOCK_TTL_MSis 20swaitForAdoptgives up afterLOCK_ADOPT_WAIT_MS= 5srefreshViaClirunsclaudewith a 60s timeout and 2 attempts — up to 120sSo the winner can hold the lock for two minutes while every loser times out after five seconds. Because the token is already past
CLI_FALLBACK_THRESHOLD_MS, theexpiresIn > 60sguard added in the first commit evaluates false, and the loser still falls through to a barenull— producing "Runclaudeto re-authenticate" while a sibling process is actively completing the refresh that resolves it seconds later.Fix
Remaining lifetime was the wrong question. A deferral means another refresher owns the work right now; nothing is known to be wrong with the credentials regardless of how much life is left.
The deferral is now recorded and exposed as
wasRefreshDeferred(source), so the sync timer can distinguish "a sibling is refreshing" from "this token is dead" instead of inferring it fromnull. The marker resets at the top of everyrefreshIfNeededcall, so it only ever describes the call that just ran and cannot suppress a later genuine failure. Still-usable credentials are returned exactly as before — this only changes what the caller is told when there is nothing left to return, and the warn-once latch is left untouched on a deferral so a real outage still reports.Three tests added, each verified to fail against the previous commit:
marks a busy-lock deferral as deferred even when it must return nullclears the deferral marker once a refresh actually runs and failsproactive refresh timer stays quiet on a deferral even when credentials are expiredNote on the underlying cause
This makes the symptom correct — the plugin no longer reports a false credential failure. It does not make the proactive refresh fire on a locked machine; that is OS timer throttling and would need a different mechanism (for example, comparing wall-clock elapsed time against the last successful check rather than relying on interval fidelity). Worth tracking separately if the maintainer wants the proactive refresh to actually hold its schedule while the machine is idle.
Checklist
feat:,fix:,docs:,chore:, etc.)make allpasses locally (runs lint, build, and test)refresh_deferred_still_usableevent is diagnostic only, andCLAUDE_AUTH_DEBUGevents are not individually documented