@@ -20,6 +20,7 @@ import (
20 "github.com/charmbracelet/crush/internal/oauth/hyper"
20 "github.com/charmbracelet/crush/internal/oauth/hyper"
21 "github.com/tidwall/gjson"
21 "github.com/tidwall/gjson"
22 "github.com/tidwall/sjson"
22 "github.com/tidwall/sjson"
23+ "golang.org/x/sync/singleflight"
25// configLockDeadline bounds how long lockConfig waits for the
26// configLockDeadline bounds how long lockConfig waits for the
@@ -27,6 +28,15 @@ import (
27// honest contention; longer suggests something is wedged.
28// honest contention; longer suggests something is wedged.
28const configLockDeadline = 5 * time.Second
29const configLockDeadline = 5 * time.Second
31+// refreshLockDeadline bounds how long RefreshOAuthToken waits for the
32+// per-provider cross-process refresh lock. It must exceed the token
33+// exchange HTTP timeout (30s) so that a peer mid-exchange is given time
34+// to finish and publish its result, which we then adopt instead of
35+// running our own exchange. Running our own would reuse an
36+// already-rotated refresh token and trip the provider's reuse detection,
37+// revoking the whole token family.
38+const refreshLockDeadline = 45 * time.Second
30// fileSnapshot captures metadata about a config file at a point in time.
40// fileSnapshot captures metadata about a config file at a point in time.
31type fileSnapshot struct {
41type fileSnapshot struct {
32 Path string
42 Path string
@@ -80,6 +90,18 @@ type ConfigStore struct {
81 mu sync.Mutex // serialises config file writes
91 mu sync.Mutex // serialises config file writes
82 writeMu sync.Mutex // serialises in-memory config production (mutators + reload)
92 writeMu sync.Mutex // serialises in-memory config production (mutators + reload)
94+ // refreshSF collapses concurrent in-process OAuth refreshes for the
95+ // same provider into a single attempt. Combined with the per-provider
96+ // cross-process refresh lock, it ensures only one token exchange runs
97+ // at a time. See RefreshOAuthToken.
98+ refreshSF singleflight.Group
100+ // exchangeToken performs the provider-specific OAuth token exchange.
101+ // It is a field so tests can substitute a fake exchange without making
102+ // real network calls. Production code leaves it nil, and exchange falls
103+ // back to the real provider clients.
104+ exchangeToken func(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error)
85// Config returns the pure-data config struct (read-only after load).
107// Config returns the pure-data config struct (read-only after load).
@@ -480,64 +502,82 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
481// RefreshOAuthToken refreshes the OAuth token for the given provider.
503// RefreshOAuthToken refreshes the OAuth token for the given provider.
483−// It uses two-phase locking: the pre-check (reading the config file to
505+// Providers like Hyper rotate refresh tokens: each exchange consumes the
484−// see if another process already refreshed) happens under the config
506+// caller's refresh token, issues a new pair, and revokes the old one. If
485−// lock, then the HTTP exchange runs without any lock held, and finally
507+// two crush instances (or two goroutines) refresh concurrently with the
486−// the result is persisted via SetConfigFields (which acquires the lock
508+// same stored refresh token, the second exchange reuses an already-revoked
487−// internally). If the exchange fails — e.g. because another process
509+// token, trips the provider's reuse detection, and revokes the entire
488−// already rotated the refresh token — the disk is re-checked under lock
510+// token family — leaving both with dead tokens even though each refresh
489−// to recover the other process's token.
511+// "succeeded".
513+// To prevent that, refreshes are single-flighted at two levels:
515+// - In-process: refreshSF collapses concurrent goroutines for the same
516+// provider into one attempt.
517+// - Cross-process: a per-provider advisory lock is held across the whole
518+// read-decide-exchange-write cycle, so only one process exchanges at a
519+// time. A process that acquires the lock after a peer rotated finds the
520+// peer's fresh token on disk and adopts it instead of exchanging.
490func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
521func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
522+ key := fmt.Sprintf("%d\x00%s", scope, providerID)
523+ _, err, _ := s.refreshSF.Do(key, func() (any, error) {
524+ return nil, s.refreshOAuthTokenLocked(ctx, scope, providerID)
529+// refreshOAuthTokenLocked performs the cross-process single-flighted
530+// refresh. It is invoked through refreshSF, so at most one goroutine per
531+// provider runs it at a time within this process.
532+func (s *ConfigStore) refreshOAuthTokenLocked(ctx context.Context, scope Scope, providerID string) error {
491 cfg := s.Config()
533 cfg := s.Config()
492 providerConfig, exists := cfg.Providers.Get(providerID)
534 providerConfig, exists := cfg.Providers.Get(providerID)
493 if !exists {
535 if !exists {
494 return fmt.Errorf("provider %s not found", providerID)
536 return fmt.Errorf("provider %s not found", providerID)
497 if providerConfig.OAuthToken == nil {
538 if providerConfig.OAuthToken == nil {
498 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
539 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
541+ entryToken := providerConfig.OAuthToken
501− // Phase 1: Pre-check under lock — did another process already refresh?
543+ // Acquire the per-provider cross-process refresh lock. This is a
502− release, lockErr := s.lockConfig(scope)
544+ // dedicated lock file, not the config-write lock, and it does not take
545+ // s.mu — so the network exchange below cannot stall unrelated config
546+ // operations. The deadline exceeds the exchange timeout so that a peer
547+ // mid-exchange has time to publish a token we can adopt. Lock ordering:
548+ // the refresh lock is always taken before the config-write lock (via
549+ // SetConfigFields), never the reverse, so no deadlock is possible.
550+ lockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)
552+ release, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))
503 if lockErr != nil {
553 if lockErr != nil {
504− slog.Warn("Failed to lock config for pre-check, proceeding anyway", "provider", providerID, "error", lockErr)
554+ // Could not acquire the lock (peer wedged or deadline hit). Prefer a
505− } else {
555+ // usable token already on disk over forcing our own exchange, which
506− diskToken, err := s.loadTokenFromDisk(scope, providerID)
556+ // would risk reusing a rotated refresh token.
507− release()
557+ if diskToken := s.adoptableDiskToken(scope, providerID, entryToken); diskToken != nil {
509− slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
558+ slog.Warn("Refresh lock unavailable; adopting token from disk", "provider", providerID, "error", lockErr)
510− } else if diskToken != nil && !diskToken.IsExpired() && diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
511− slog.Info("Using token refreshed by another session", "provider", providerID)
512 return s.applyToken(providerConfig, diskToken, providerID)
559 return s.applyToken(providerConfig, diskToken, providerID)
561+ return fmt.Errorf("acquire refresh lock for provider %s: %w", providerID, lockErr)
516− // Phase 2: HTTP exchange — no lock held.
565+ // Did a peer rotate the token while we waited for the lock? If disk now
517− var refreshedToken *oauth.Token
566+ // holds a different, unexpired token, adopt it instead of exchanging.
518− var refreshErr error
567+ if diskToken := s.adoptableDiskToken(scope, providerID, entryToken); diskToken != nil {
519− switch providerID {
568+ slog.Info("Adopting token refreshed by another session", "provider", providerID)
520− case string(catwalk.InferenceProviderCopilot):
569+ return s.applyToken(providerConfig, diskToken, providerID)
521− refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
523− refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
525− return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
572+ // Disk still holds our token (or no usable peer token exists) and we hold
573+ // the lock, so we are the sole exchanger. Perform the exchange.
574+ refreshedToken, refreshErr := s.exchange(ctx, providerID, entryToken.RefreshToken)
527 if refreshErr != nil {
575 if refreshErr != nil {
528− // Phase 3: Fallback — re-check disk under lock. The exchange may
529− // have failed because another process already rotated the refresh
576+ // The exchange may have failed because a peer rotated the refresh
530− // token.
577+ // token in a window we did not cover. Re-check disk and adopt.
531− if release, lockErr := s.lockConfig(scope); lockErr == nil {
578+ if diskToken := s.adoptableDiskToken(scope, providerID, entryToken); diskToken != nil {
532− diskToken, diskErr := s.loadTokenFromDisk(scope, providerID)
534− if diskErr == nil &&
536− !diskToken.IsExpired() &&
537− diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
538− slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
579+ slog.Info("Adopting token refreshed by another session after exchange failure", "provider", providerID)
539− return s.applyToken(providerConfig, diskToken, providerID)
580+ return s.applyToken(providerConfig, diskToken, providerID)
542 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
582 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
@@ -545,12 +585,9 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
545 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
585 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
546 providerConfig.OAuthToken = refreshedToken
586 providerConfig.OAuthToken = refreshedToken
547 providerConfig.APIKey = refreshedToken.AccessToken
587 providerConfig.APIKey = refreshedToken.AccessToken
550− case string(catwalk.InferenceProviderCopilot):
588+ if providerID == string(catwalk.InferenceProviderCopilot) {
551 providerConfig.SetupGitHubCopilot()
589 providerConfig.SetupGitHubCopilot()
554 cfg.Providers.Set(providerID, providerConfig)
591 cfg.Providers.Set(providerID, providerConfig)
556 if err := s.SetConfigFields(scope, map[string]any{
593 if err := s.SetConfigFields(scope, map[string]any{
@@ -559,10 +596,57 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
559 }); err != nil {
596 }); err != nil {
560 return fmt.Errorf("failed to persist refreshed token: %w", err)
597 return fmt.Errorf("failed to persist refreshed token: %w", err)
563 return nil
599 return nil
602+// adoptableDiskToken returns the on-disk token for the provider when it is
603+// usable and differs from entryToken — i.e. when another session has
604+// already refreshed it and we should adopt that result rather than running
605+// our own exchange. It returns nil when there is nothing newer to adopt.
606+func (s *ConfigStore) adoptableDiskToken(scope Scope, providerID string, entryToken *oauth.Token) *oauth.Token {
607+ diskToken, err := s.loadTokenFromDisk(scope, providerID)
609+ slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
612+ if diskToken == nil || diskToken.IsExpired() {
615+ if diskToken.AccessToken == entryToken.AccessToken {
616+ // Same token we started with; nobody refreshed since.
622+// exchange performs the provider-specific OAuth token exchange. Tests may
623+// override it via the exchangeToken field; production uses the real
625+func (s *ConfigStore) exchange(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error) {
626+ if s.exchangeToken != nil {
627+ return s.exchangeToken(ctx, providerID, refreshToken)
630+ case string(catwalk.InferenceProviderCopilot):
631+ return copilot.RefreshToken(ctx, refreshToken)
633+ return hyper.ExchangeToken(ctx, refreshToken)
635+ return nil, fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
639+// refreshLockPath returns the path to the per-provider cross-process refresh
640+// lock file. Lock files live under a dedicated locks/ subdirectory of the
641+// data dir so they do not clutter the config directory. The file is created
642+// on demand by lock.File and is never removed (flock keys on inode, not
644+func (s *ConfigStore) refreshLockPath(providerID string) string {
645+ dir := filepath.Join(filepath.Dir(s.globalDataPath), "locks")
646+ _ = os.MkdirAll(dir, 0o755)
647+ return filepath.Join(dir, fmt.Sprintf("%s.refresh.lock", providerID))
566// applyToken updates the in-memory provider config with the given token.
650// applyToken updates the in-memory provider config with the given token.
567func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
651func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
568 providerConfig.OAuthToken = token
652 providerConfig.OAuthToken = token