Skip to content

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

Open

Description

@Toady00

Description

refreshIfNeeded() returns 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 for "no usable credentials exist". src/index.ts:236-255 reads it that way and shows the user:

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

The proactive sync timer asks an hour ahead of expiry (PROACTIVE_REFRESH_THRESHOLD_MS), so the token those branches are asked about is normally still perfectly healthy. The result is a "re-authenticate" prompt on a credential with hours of life left, and running claude does nothing because nothing is wrong.

performRefresh already has the correct guard for exactly this situation, twice:

  • src/credentials.ts:692 — if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) return creds on the transient path
  • src/credentials.ts:722 — the same test before the CLI fallback

Only the two deferral branches are missing it.

Why it shows up so often. Every OpenCode process runs its own sync timer against one shared Claude OAuth credential and one advisory lock file. Each token rotation is therefore a race that exactly one process wins; every loser takes the lock-busy branch. On this machine there are 10 concurrent OpenCode processes, and the keychain entry rotated at 10:22:45 local — the same minute the warning appeared. Token TTL is 8h and refresh starts 1h out, so the burst recurs roughly every 7 hours.

proactiveRefreshWarned in src/index.ts:226 does latch the warning to once per outage per process, so this is not a 5-minute repeat within one process — it is one warning per losing process per rotation.

Steps to reproduce

Reproduced end-to-end against the published 2.1.6 dist with real (healthy) keychain credentials. No OAuth request, no keychain write, no claude spawn — the lock is taken before anything else runs, and the lock directory is redirected so live OpenCode instances are unaffected.

// repro.mjs
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR = mkdtempSync(
  join(tmpdir(), "claude-auth-repro-lock-"),
)
process.env.CLAUDE_AUTH_DEBUG = process.argv[3]

const dist = process.argv[2]
const { initLogger } = await import(`${dist}/logger.js`)
const { acquireRefreshLock } = await import(`${dist}/refresh-lock.js`)
const { initAccounts, refreshIfNeeded } = await import(`${dist}/credentials.js`)
const { readAllClaudeAccounts } = await import(`${dist}/keychain.js`)

initLogger()

const accounts = readAllClaudeAccounts()
initAccounts(accounts)
const target = accounts[0]
const ttlMin = Math.round((target.credentials.expiresAt - Date.now()) / 60_000)
console.log(`token life left: ${ttlMin} min`)

// Simulate a sibling OpenCode process that won the refresh race.
const held = acquireRefreshLock(target.source)

// A threshold above the remaining life forces the "needs refresh" path,
// exactly as the 1h proactive timer does once the token is inside its window.
const result = await refreshIfNeeded(undefined, (ttlMin + 60) * 60_000)
console.log(`refreshIfNeeded returned: ${result ? "credentials" : "null"}`)
held?.release()
node repro.mjs ~/.cache/opencode/packages/[email protected]/node_modules/opencode-claude-auth/dist /tmp/debug.log

Output:

token life left: 470 min
refreshIfNeeded returned: null

470 minutes of remaining life, and index.ts would tell the user to re-authenticate.

Expected: the still-usable credentials are returned and the next tick (5 min later) retries the refresh.

Operating system

macOS

Plugin version

2.1.6

OpenCode version

1.18.18

Debug logs

CLAUDE_AUTH_DEBUG output from the run above, with the repeated keychain_read / credentials_parsed poll lines trimmed:

{"ts":"2026-08-15T14:32:52.826Z","event":"keychain_list","servicesFound":["Claude Code-credentials"]}
{"ts":"2026-08-15T14:32:52.843Z","event":"account_config_dir","source":"Claude Code-credentials","configDir":"/Users/…/.claude"}
{"ts":"2026-08-15T14:32:52.844Z","event":"refresh_lock_acquired","source":"Claude Code-credentials"}
{"ts":"2026-08-15T14:32:52.859Z","event":"refresh_lock_busy","source":"Claude Code-credentials"}
{"ts":"2026-08-15T14:32:58.044Z","event":"keychain_read","service":"Claude Code-credentials","success":true}

The trace ends at refresh_lock_busy plus 5s of adopt polling. There is no refresh_failed, no refresh_terminal, no refresh_exhausted — nothing actually failed. proactive_refresh_failed and the user-facing warning follow purely from the null.

Suggested fix

Apply the same guard the transient and CLI-fallback paths already use to both deferral branches: if the credentials still have more than CLI_FALLBACK_THRESHOLD_MS (60s) of life, return them instead of null.

That keeps the reactive path (getCachedCredentials, 60s threshold) byte-for-byte unchanged — it only reaches these branches when the token has under 60s left, where the guard is false and null still stands, so getCredentialsWithBackoff continues to wait the cooldown out. It only changes the proactive path, which is the one producing the false alarm. It also makes a null from the proactive timer mean something real again: under 60s of life and unable to refresh, which genuinely warrants "run claude".

Aside, not part of this report

An earlier analysis of this also suggested the plugin leaks its setInterval across instance disposal because it never returns a dispose hook. I could not confirm that: Hooks in @opencode-ai/plugin (checked 1.2.25 and 1.2.27) has no dispose member, so a plugin cannot currently return one. The timer is unref()'d at src/index.ts:261. Mentioning it only so it is not lost — it is not what this issue is about, and the fix above stands on its own.

Activity

  1. Toady00 commented on Aug 16, 2026

    @Toady00
    Author

    Follow-up: the original report covered the case where the token is still healthy, but field testing turned up a second, more common trigger that the first fix does not cover. PR #273 has been extended.

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions