Skip to content

fix: stop reporting a healthy token as expired when refresh is deferred - #273

Open
Toady00 wants to merge 3 commits into
griffinmartin:mainfrom
Toady00:fix/proactive-refresh-deferral-false-alarm
Open

fix: stop reporting a healthy token as expired when refresh is deferred#273
Toady00 wants to merge 3 commits into
griffinmartin:mainfrom
Toady00:fix/proactive-refresh-deferral-false-alarm

Conversation

@Toady00

@Toady00 Toady00 commented Aug 15, 2026

Copy link
Copy Markdown

Summary

refreshIfNeeded() returned null for two conditions that are deferrals, not credential failures:

  • src/credentials.ts:513-524 — an armed rate-limit cooldown
  • src/credentials.ts:539-549 — a sibling process holds the cross-process refresh lock and publishes nothing inside the 5s waitForAdopt budget

null is this module's signal that no usable credentials exist, so the proactive sync timer at src/index.ts:236-255 turned both into:

opencode-claude-auth: Proactive token refresh failed. Run `claude` to re-authenticate.

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 claude fixes nothing, because nothing broke.

performRefresh already guards this exact case, twice:

  • src/credentials.ts:692 — the transient path
  • src/credentials.ts:722 — before the CLI fallback

Both 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 shared deferToUsableCredentials() helper that also emits a refresh_deferred_still_usable diagnostic.

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 null still stands, and getCredentialsWithBackoff goes on to wait the cooldown out as before.

Caller Token life left Deferral hit Before After
Proactive timer (1h) > 60s cooldown null → re-authenticate warning credentials, retry next tick
Proactive timer (1h) > 60s lock busy null → re-authenticate warning credentials, retry next tick
Proactive timer (1h) ≤ 60s either null null (unchanged)
getCachedCredentials (60s) ≤ 60s either null null (unchanged)

A side effect worth stating: a null from 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 all passes: lint clean (4 pre-existing warnings in src/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 with undefined vs "existing-token"
  • keeps serving still-usable credentials when the lock holder publishes nothing — failed with undefined vs "existing-token"
  • proactive refresh timer stays quiet when a refresh is merely deferred (end-to-end through index.ts, asserts the toast is not emitted) — failed with the warning present
  • still 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 tokens
  • still returns null on a busy lock when credentials are past the reactive window — same, for the lock branch

Manual verification against real keychain credentials, using a read-only harness (no OAuth request, no keychain write, no claude spawn, lock directory redirected so live OpenCode instances were unaffected):

  • Published 2.1.6 dist, token with 470 minutes of life left, sibling holding the lock → refreshIfNeeded returned null. Debug log ends at refresh_lock_busy plus 5s of adopt polling: no refresh_failed, no refresh_terminal, no refresh_exhausted. Nothing failed.
  • Same harness against this branch's build → returned credentials, and logged:
{"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 setInterval in src/index.ts does 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:

  • Token expired at 16:00:21, so the proactive window opened at 15:00:21
  • Keychain mdat shows the credential was rotated at 20260816200021Z = 16:00:21 local — exactly at expiry, not during the hour-long window
  • Nothing refreshed it for that entire hour

It 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_MS is 20s
  • waitForAdopt gives up after LOCK_ADOPT_WAIT_MS = 5s
  • but refreshViaCli runs claude with a 60s timeout and 2 attempts — up to 120s

So 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, the expiresIn > 60s guard added in the first commit evaluates false, and the loser still falls through to a bare null — producing "Run claude to 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 from null. The marker resets at the top of every refreshIfNeeded call, 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 null
  • clears the deferral marker once a refresh actually runs and fails
  • proactive refresh timer stays quiet on a deferral even when credentials are expired

Note 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

  • PR title follows Conventional Commits (feat:, fix:, docs:, chore:, etc.)
  • make all passes locally (runs lint, build, and test)
  • Tests added or updated where applicable
  • README or docs updated where applicable — no user-facing surface changed; the new refresh_deferred_still_usable event is diagnostic only, and CLAUDE_AUTH_DEBUG events are not individually documented

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

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@rrebollo

Copy link
Copy Markdown

this need to be merged

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proactive refresh reports a healthy token as expired when a refresh is merely deferred

2 participants