@@ -8,10 +8,13 @@ import (
9 "path/filepath"
9 "path/filepath"
12 "charm.land/catwalk/pkg/catwalk"
14 "charm.land/catwalk/pkg/catwalk"
13 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
15 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/env"
16 "github.com/charmbracelet/crush/internal/env"
17+ "github.com/charmbracelet/crush/internal/lock"
15 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
16 "github.com/charmbracelet/crush/internal/oauth/copilot"
19 "github.com/charmbracelet/crush/internal/oauth/copilot"
17 "github.com/charmbracelet/crush/internal/oauth/hyper"
20 "github.com/charmbracelet/crush/internal/oauth/hyper"
@@ -19,6 +22,11 @@ import (
19 "github.com/tidwall/sjson"
22 "github.com/tidwall/sjson"
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
22// fileSnapshot captures metadata about a config file at a point in time.
30// fileSnapshot captures metadata about a config file at a point in time.
23type fileSnapshot struct {
31type fileSnapshot struct {
24 Path string
32 Path string
@@ -37,6 +45,11 @@ type RuntimeOverrides struct {
37// ConfigStore is the single entry point for all config access. It owns the
45// ConfigStore is the single entry point for all config access. It owns the
38// pure-data Config, runtime state (working directory, resolver, known
46// pure-data Config, runtime state (working directory, resolver, known
39// providers), and persistence to both global and workspace config files.
47// providers), and persistence to both global and workspace config files.
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.
40type ConfigStore struct {
53type ConfigStore struct {
41 config *Config
54 config *Config
42 workingDir string
55 workingDir string
@@ -50,6 +63,8 @@ type ConfigStore struct {
50 snapshots map[string]fileSnapshot // path -> snapshot at last capture
63 snapshots map[string]fileSnapshot // path -> snapshot at last capture
51 autoReloadDisabled bool // set during load/reload to prevent re-entrancy
64 autoReloadDisabled bool // set during load/reload to prevent re-entrancy
52 reloadInProgress bool // set during reload to avoid disk writes mid-reload
65 reloadInProgress bool // set during reload to avoid disk writes mid-reload
55// Config returns the pure-data config struct (read-only after load).
70// Config returns the pure-data config struct (read-only after load).
@@ -95,6 +110,74 @@ func (s *ConfigStore) LoadedPaths() []string {
95 return slices.Clone(s.loadedPaths)
110 return slices.Clone(s.loadedPaths)
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.
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) {
123+ path, err := s.configPath(scope)
128+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130+ return nil, fmt.Errorf("create config directory: %w", err)
132+ ctx, cancel := context.WithTimeout(context.Background(), configLockDeadline)
134+ release, err := lock.File(ctx, path+".lock")
137+ return nil, fmt.Errorf("acquire config lock: %w", err)
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)
156+ path, err := s.configPath(scope)
161+ data, err := os.ReadFile(path)
163+ if os.IsNotExist(err) {
166+ return fmt.Errorf("read config file: %w", err)
170+ newData, err := fn(data)
175+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
176+ return fmt.Errorf("create config directory: %w", err)
178+ return atomicWriteFile(path, newData, 0o600)
98// configPath returns the file path for the given scope.
181// configPath returns the file path for the given scope.
99func (s *ConfigStore) configPath(scope Scope) (string, error) {
182func (s *ConfigStore) configPath(scope Scope) (string, error) {
100 switch scope {
183 switch scope {
@@ -134,32 +217,23 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
134// config to keep in-memory state fresh. This is preferred over multiple
217// config to keep in-memory state fresh. This is preferred over multiple
135// SetConfigField calls when writing several fields atomically to avoid
218// SetConfigField calls when writing several fields atomically to avoid
136// intermediate reloads with partial state.
219// intermediate reloads with partial state.
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.
137func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
223func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
138− path, err := s.configPath(scope)
224+ err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
140− return fmt.Errorf("%v: %w", kv, err)
142− data, err := os.ReadFile(path)
144− if os.IsNotExist(err) {
147− return fmt.Errorf("failed to read config file: %w", err)
151− newValue := string(data)
225+ v := string(data)
152− for key, value := range kv {
226+ for key, value := range kv {
153− newValue, err = sjson.Set(newValue, key, value)
228+ v, sErr = sjson.Set(v, key, value)
154− if err != nil {
229+ if sErr != nil {
155− return fmt.Errorf("failed to set config field %s: %w", key, err)
230+ return nil, fmt.Errorf("failed to set config field %s: %w", key, sErr)
157− }
233+ return []byte(v), nil
158− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
234+ })
159− return fmt.Errorf("failed to create config directory %q: %w", path, err)
235+ if err != nil {
161− if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162− return fmt.Errorf("failed to write config file: %w", err)
165 // Auto-reload to keep in-memory state fresh after config edits.
239 // 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 {
176// RemoveConfigField removes a key from the config file for the given scope.
250// RemoveConfigField removes a key from the config file for the given scope.
177// After a successful write, it automatically reloads config to keep in-memory
251// After a successful write, it automatically reloads config to keep in-memory
178// state fresh.
252// state fresh.
254+// The write is protected by an in-process mutex and a cross-process flock.
179func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
255func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
180− path, err := s.configPath(scope)
256+ err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
182− return fmt.Errorf("%s: %w", key, err)
184− data, err := os.ReadFile(path)
186− return fmt.Errorf("failed to read config file: %w", err)
189− newValue, err := sjson.Delete(string(data), key)
257+ v, sErr := sjson.Delete(string(data), key)
259+ return nil, fmt.Errorf("failed to delete config field %s: %w", key, sErr)
261+ return []byte(v), nil
190 if err != nil {
263 if err != nil {
191− return fmt.Errorf("failed to delete config field %s: %w", key, err)
264+ return err
193− if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194− return fmt.Errorf("failed to create config directory %q: %w", path, err)
196− if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197− return fmt.Errorf("failed to write config file: %w", err)
200− // Auto-reload to keep in-memory state fresh after config edits.
201 if err := s.autoReload(context.Background()); err != nil {
267 if err := s.autoReload(context.Background()); err != nil {
202 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
268 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
@@ -300,12 +366,14 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
368// RefreshOAuthToken refreshes the OAuth token for the given provider.
303−// Before making an external refresh request, it checks the config file on
369+//
370+// It uses two-phase locking: the pre-check (reading the config file to
304−// disk to see if another Crush session has already refreshed the token. If
371+// see if another process already refreshed) happens under the config
305−// a newer, unexpired token is found, it is used instead of refreshing. If
372+// lock, then the HTTP exchange runs without any lock held, and finally
373+// the result is persisted via SetConfigFields (which acquires the lock
306−// the exchange fails (e.g. because another session already rotated the
374+// internally). If the exchange fails — e.g. because another process
307−// refresh token), the disk is re-checked to recover the other session's
375+// already rotated the refresh token — the disk is re-checked under lock
308−// token.
376+// to recover the other process's token.
309func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
377func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
310 providerConfig, exists := s.config.Providers.Get(providerID)
378 providerConfig, exists := s.config.Providers.Get(providerID)
311 if !exists {
379 if !exists {
@@ -316,16 +384,22 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
316 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
384 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
319− // Check if another session refreshed the token recently by reading
387+ // Phase 1: Pre-check under lock — did another process already refresh?
320− // the current token from the config file on disk.
388+ release, lockErr := s.lockConfig(scope)
390+ slog.Warn("Failed to lock config for pre-check, proceeding anyway", "provider", providerID, "error", lockErr)
321− newToken, err := s.loadTokenFromDisk(scope, providerID)
392+ diskToken, err := s.loadTokenFromDisk(scope, providerID)
322− if err != nil {
394+ if err != nil {
323− slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
395+ slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
324− } else if newToken != nil && !newToken.IsExpired() && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
396+ } else if diskToken != nil && !diskToken.IsExpired() && diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
325− slog.Info("Using token refreshed by another session", "provider", providerID)
397+ slog.Info("Using token refreshed by another session", "provider", providerID)
326− return s.applyToken(providerConfig, newToken, providerID)
398+ return s.applyToken(providerConfig, diskToken, providerID)
402+ // Phase 2: HTTP exchange — no lock held.
329 var refreshedToken *oauth.Token
403 var refreshedToken *oauth.Token
330 var refreshErr error
404 var refreshErr error
331 switch providerID {
405 switch providerID {
@@ -337,15 +411,19 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
337 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
411 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
339 if refreshErr != nil {
413 if refreshErr != nil {
414+ // Phase 3: Fallback — re-check disk under lock. The exchange may
340− // The exchange may have failed because another session already
415+ // have failed because another process already rotated the refresh
341− // rotated the refresh token. Re-read the config file and use the
416+ // token.
342− // other session's token if available.
417+ if release, lockErr := s.lockConfig(scope); lockErr == nil {
343− if diskToken, diskErr := s.loadTokenFromDisk(scope, providerID); diskErr == nil &&
418+ diskToken, diskErr := s.loadTokenFromDisk(scope, providerID)
420+ if diskErr == nil &&
344− diskToken != nil &&
421+ diskToken != nil &&
345− !diskToken.IsExpired() &&
422+ !diskToken.IsExpired() &&
346− diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
423+ diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
347− slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
424+ slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
348− return s.applyToken(providerConfig, diskToken, providerID)
425+ return s.applyToken(providerConfig, diskToken, providerID)
350 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
428 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)