@@ -8,10 +8,13 @@ import (
88 "os"
99 "path/filepath"
1010 "slices"
11+ "sync"
12+ "time"
1113
1214 "charm.land/catwalk/pkg/catwalk"
1315 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1416 "github.com/charmbracelet/crush/internal/env"
17+ "github.com/charmbracelet/crush/internal/lock"
1518 "github.com/charmbracelet/crush/internal/oauth"
1619 "github.com/charmbracelet/crush/internal/oauth/copilot"
1720 "github.com/charmbracelet/crush/internal/oauth/hyper"
@@ -19,6 +22,11 @@ import (
1922 "github.com/tidwall/sjson"
2023)
2124
25+// configLockDeadline bounds how long lockConfig waits for the
26+// cross-process flock before giving up. A few seconds is plenty for
27+// honest contention; longer suggests something is wedged.
28+const configLockDeadline = 5 * time.Second
29+
2230// fileSnapshot captures metadata about a config file at a point in time.
2331type fileSnapshot struct {
2432 Path string
@@ -37,6 +45,11 @@ type RuntimeOverrides struct {
3745// ConfigStore is the single entry point for all config access. It owns the
3846// pure-data Config, runtime state (working directory, resolver, known
3947// providers), and persistence to both global and workspace config files.
48+//
49+// mu serialises all config file mutations (SetConfigFields,
50+// RemoveConfigField, RefreshOAuthToken) to prevent both in-process
51+// goroutine races and, together with the shared lock.File, cross-process
52+// races on the config file.
4053type ConfigStore struct {
4154 config *Config
4255 workingDir string
@@ -50,6 +63,8 @@ type ConfigStore struct {
5063 snapshots map[string]fileSnapshot // path -> snapshot at last capture
5164 autoReloadDisabled bool // set during load/reload to prevent re-entrancy
5265 reloadInProgress bool // set during reload to avoid disk writes mid-reload
66+
67+ mu sync.Mutex
5368}
5469
5570// Config returns the pure-data config struct (read-only after load).
@@ -95,6 +110,74 @@ func (s *ConfigStore) LoadedPaths() []string {
95110 return slices.Clone(s.loadedPaths)
96111}
97112
113+// lockConfig acquires both the in-process mutex and a cross-process flock
114+// on the config file for the given scope. Callers that need to do I/O
115+// between reading and writing (e.g. an HTTP token exchange) must use
116+// lockConfig explicitly rather than atomicWrite.
117+//
118+// The returned release function drops both locks. Callers must call it
119+// as soon as the file access is complete — no I/O should be performed
120+// while the lock is held.
121+func (s *ConfigStore) lockConfig(scope Scope) (func(), error) {
122+ s.mu.Lock()
123+ path, err := s.configPath(scope)
124+ if err != nil {
125+ s.mu.Unlock()
126+ return nil, err
127+ }
128+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
129+ s.mu.Unlock()
130+ return nil, fmt.Errorf("create config directory: %w", err)
131+ }
132+ ctx, cancel := context.WithTimeout(context.Background(), configLockDeadline)
133+ defer cancel()
134+ release, err := lock.File(ctx, path+".lock")
135+ if err != nil {
136+ s.mu.Unlock()
137+ return nil, fmt.Errorf("acquire config lock: %w", err)
138+ }
139+ return func() {
140+ release()
141+ s.mu.Unlock()
142+ }, nil
143+}
144+
145+// atomicWrite handles the lock-read-transform-write-unlock cycle for
146+// config file mutations. The fn callback receives the current file
147+// contents (raw bytes, or {} if the file is missing) and must return the
148+// new contents. fn must be pure — no I/O, no network calls.
149+func (s *ConfigStore) atomicWrite(scope Scope, fn func(current []byte) ([]byte, error)) error {
150+ unlock, err := s.lockConfig(scope)
151+ if err != nil {
152+ return err
153+ }
154+ defer unlock()
155+
156+ path, err := s.configPath(scope)
157+ if err != nil {
158+ return err
159+ }
160+
161+ data, err := os.ReadFile(path)
162+ if err != nil {
163+ if os.IsNotExist(err) {
164+ data = []byte("{}")
165+ } else {
166+ return fmt.Errorf("read config file: %w", err)
167+ }
168+ }
169+
170+ newData, err := fn(data)
171+ if err != nil {
172+ return err
173+ }
174+
175+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
176+ return fmt.Errorf("create config directory: %w", err)
177+ }
178+ return atomicWriteFile(path, newData, 0o600)
179+}
180+
98181// configPath returns the file path for the given scope.
99182func (s *ConfigStore) configPath(scope Scope) (string, error) {
100183 switch scope {
@@ -134,32 +217,23 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
134217// config to keep in-memory state fresh. This is preferred over multiple
135218// SetConfigField calls when writing several fields atomically to avoid
136219// intermediate reloads with partial state.
220+//
221+// The write is protected by an in-process mutex and a cross-process flock
222+// to prevent races between concurrent writers in different processes.
137223func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
138− path, err := s.configPath(scope)
139− if err != nil {
140− return fmt.Errorf("%v: %w", kv, err)
141− }
142− data, err := os.ReadFile(path)
143− if err != nil {
144− if os.IsNotExist(err) {
145− data = []byte("{}")
146− } else {
147− return fmt.Errorf("failed to read config file: %w", err)
148− }
149− }
150−
151− newValue := string(data)
152− for key, value := range kv {
153− newValue, err = sjson.Set(newValue, key, value)
154− if err != nil {
155− return fmt.Errorf("failed to set config field %s: %w", key, err)
224+ err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
225+ v := string(data)
226+ for key, value := range kv {
227+ var sErr error
228+ v, sErr = sjson.Set(v, key, value)
229+ if sErr != nil {
230+ return nil, fmt.Errorf("failed to set config field %s: %w", key, sErr)
231+ }
156232 }
157− }
158− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159− return fmt.Errorf("failed to create config directory %q: %w", path, err)
160− }
161− if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162− return fmt.Errorf("failed to write config file: %w", err)
233+ return []byte(v), nil
234+ })
235+ if err != nil {
236+ return err
163237 }
164238
165239 // Auto-reload to keep in-memory state fresh after config edits.
@@ -176,28 +250,20 @@ func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
176250// RemoveConfigField removes a key from the config file for the given scope.
177251// After a successful write, it automatically reloads config to keep in-memory
178252// state fresh.
253+//
254+// The write is protected by an in-process mutex and a cross-process flock.
179255func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
180− path, err := s.configPath(scope)
181− if err != nil {
182− return fmt.Errorf("%s: %w", key, err)
183− }
184− data, err := os.ReadFile(path)
185− if err != nil {
186− return fmt.Errorf("failed to read config file: %w", err)
187− }
188−
189− newValue, err := sjson.Delete(string(data), key)
256+ err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
257+ v, sErr := sjson.Delete(string(data), key)
258+ if sErr != nil {
259+ return nil, fmt.Errorf("failed to delete config field %s: %w", key, sErr)
260+ }
261+ return []byte(v), nil
262+ })
190263 if err != nil {
191− return fmt.Errorf("failed to delete config field %s: %w", key, err)
192− }
193− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194− return fmt.Errorf("failed to create config directory %q: %w", path, err)
195− }
196− if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197− return fmt.Errorf("failed to write config file: %w", err)
264+ return err
198265 }
199266
200− // Auto-reload to keep in-memory state fresh after config edits.
201267 if err := s.autoReload(context.Background()); err != nil {
202268 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
203269 }
@@ -300,12 +366,14 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
300366}
301367
302368// RefreshOAuthToken refreshes the OAuth token for the given provider.
303−// Before making an external refresh request, it checks the config file on
304−// disk to see if another Crush session has already refreshed the token. If
305−// a newer, unexpired token is found, it is used instead of refreshing. If
306−// the exchange fails (e.g. because another session already rotated the
307−// refresh token), the disk is re-checked to recover the other session's
308−// token.
369+//
370+// It uses two-phase locking: the pre-check (reading the config file to
371+// see if another process already refreshed) happens under the config
372+// lock, then the HTTP exchange runs without any lock held, and finally
373+// the result is persisted via SetConfigFields (which acquires the lock
374+// internally). If the exchange fails — e.g. because another process
375+// already rotated the refresh token — the disk is re-checked under lock
376+// to recover the other process's token.
309377func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
310378 providerConfig, exists := s.config.Providers.Get(providerID)
311379 if !exists {
@@ -316,16 +384,22 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
316384 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
317385 }
318386
319− // Check if another session refreshed the token recently by reading
320− // the current token from the config file on disk.
321− newToken, err := s.loadTokenFromDisk(scope, providerID)
322− if err != nil {
323− slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
324− } else if newToken != nil && !newToken.IsExpired() && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
325− slog.Info("Using token refreshed by another session", "provider", providerID)
326− return s.applyToken(providerConfig, newToken, providerID)
387+ // Phase 1: Pre-check under lock — did another process already refresh?
388+ release, lockErr := s.lockConfig(scope)
389+ if lockErr != nil {
390+ slog.Warn("Failed to lock config for pre-check, proceeding anyway", "provider", providerID, "error", lockErr)
391+ } else {
392+ diskToken, err := s.loadTokenFromDisk(scope, providerID)
393+ release()
394+ if err != nil {
395+ slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
396+ } else if diskToken != nil && !diskToken.IsExpired() && diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
397+ slog.Info("Using token refreshed by another session", "provider", providerID)
398+ return s.applyToken(providerConfig, diskToken, providerID)
399+ }
327400 }
328401
402+ // Phase 2: HTTP exchange — no lock held.
329403 var refreshedToken *oauth.Token
330404 var refreshErr error
331405 switch providerID {
@@ -337,15 +411,19 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
337411 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
338412 }
339413 if refreshErr != nil {
340− // The exchange may have failed because another session already
341− // rotated the refresh token. Re-read the config file and use the
342− // other session's token if available.
343− if diskToken, diskErr := s.loadTokenFromDisk(scope, providerID); diskErr == nil &&
344− diskToken != nil &&
345− !diskToken.IsExpired() &&
346− diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
347− slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
348− return s.applyToken(providerConfig, diskToken, providerID)
414+ // Phase 3: Fallback — re-check disk under lock. The exchange may
415+ // have failed because another process already rotated the refresh
416+ // token.
417+ if release, lockErr := s.lockConfig(scope); lockErr == nil {
418+ diskToken, diskErr := s.loadTokenFromDisk(scope, providerID)
419+ release()
420+ if diskErr == nil &&
421+ diskToken != nil &&
422+ !diskToken.IsExpired() &&
423+ diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
424+ slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
425+ return s.applyToken(providerConfig, diskToken, providerID)
426+ }
349427 }
350428 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
351429 }