charmbracelet/crush · error
acquire refresh lock for provider %s: %w
Error message
acquire refresh lock for provider %s: %w
What it means
refreshOAuthTokenLocked tries to acquire a cross-process per-provider file lock before refreshing an OAuth token. If lock acquisition fails AND no usable token exists on disk to adopt, the underlying lock error (e.g. flock failure or timeout) is wrapped and returned. This is a guard against concurrent refresh races corrupting rotated refresh tokens.
Source
Thrown at internal/config/store.go:698
// Acquire the per-provider cross-process refresh lock. This is a
// dedicated lock file, not the config-write lock, and it does not take
// s.mu — so the network exchange below cannot stall unrelated config
// operations. The deadline exceeds the exchange timeout so that a peer
// mid-exchange has time to publish a token we can adopt. Lock ordering:
// the refresh lock is always taken before the config-write lock (via
// SetConfigFields), never the reverse, so no deadlock is possible.
lockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)
defer cancel()
release, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))
if lockErr != nil {
// Could not acquire the lock (peer wedged or deadline hit). Prefer a
// usable token already on disk over forcing our own exchange, which
// would risk reusing a rotated refresh token.
if diskToken := s.usableDiskToken(scope, providerID, entryToken); diskToken != nil {
slog.Warn("Refresh lock unavailable; adopting token from disk", "provider", providerID, "error", lockErr)
return s.applyToken(providerConfig, diskToken, providerID)
}
return fmt.Errorf("acquire refresh lock for provider %s: %w", providerID, lockErr)
}
defer release()
// Now that we hold the lock, disk is the authority on which credential
// is current: a peer may have rotated ours away while we waited. Adopt
// a newer token outright when it is still usable, and otherwise switch
// to its refresh token for the exchange below. Presenting our own
// already-rotated refresh token would trip the provider's reuse
// detection and revoke the whole family, forcing an interactive login.
if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
if !diskToken.IsExpired() {
slog.Info("Adopting token refreshed by another session", "provider", providerID)
return s.applyToken(providerConfig, diskToken, providerID)
}
slog.Info("Exchanging with refresh token rotated by another session", "provider", providerID)
entryToken = diskToken
}
View on GitHub (pinned to 7944b8e522)
Solutions
- Ensure the config directory is writable and on a local filesystem that supports file locking (avoid NFS).
- Re-run after the peer process finishes its refresh so the lock is free.
- Check diskToken state: if a valid token is on disk, the code already adopts it; fix whatever made usableDiskToken return nil (e.g. clock skew, wrong providerID).
- Restart stale processes holding the lock; remove stale lock files if safe.
Example fix
// before: lock unavailable, no disk token
return fmt.Errorf("acquire refresh lock for provider %s: %w", providerID, lockErr)
// after (caller side): back off and retry the refresh once
if err := refresh(); err != nil {
time.Sleep(2 * time.Second)
err = refresh() // peer likely released the lock
} Defensive patterns
Strategy: retry
Validate before calling
if _, err := os.Stat(lockPath); err != nil || !isLocalFS(lockPath) { log("refresh lock unavailable") } Try / catch
err := store.RefreshToken(ctx, providerID)
if errors.Is(err, errRefreshLockUnavailable) {
time.Sleep(retryDelay); err = store.RefreshToken(ctx, providerID)
} Prevention
- Keep the config directory on a local, writable filesystem
- Avoid running many concurrent sessions that refresh the same provider token
- Clear stale lock files after crashed processes
When it happens
Trigger: Calling refreshOAuthTokenLocked when withRefreshLock cannot acquire the per-provider lock (lock file unwritable, another process holds it past the wait budget, or filesystem does not support locking) and usableDiskToken returns nil because the on-disk token is missing, expired, or for a different credential.
Common situations: Many Crush instances sharing one config directory refreshing the same provider token simultaneously; read-only or NFS-mounted config dirs where flock is unavailable; a stale lock held by a crashed process.
Related errors
- file lock is held by another process
- mcp '%s' already has an authentication in progress
- server is hosting live workspaces
- github copilot not available
- ${ErrorDescription}
AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29).
Data as JSON: /api/errors/65463f3b92c55d72.
Report an issue: GitHub.