on main help sign in
refactor(config): replace reload booleans with reloadMu mutex

Replace autoReloadDisabled and reloadInProgress boolean guards with a sync.Mutex (reloadMu) to serialize ReloadFromDisk calls. autoReload uses TryLock to skip redundant reloads when one is already in progress, handling both concurrent auto-reloads after parallel writes and re-entrant calls from configureProviders during a reload. The autoReload comment now honestly documents the lost-update window: if a write completes after the in-progress reload has already read the config file, that change won't appear in memory until the next reload. Callers needing guaranteed freshness should call ReloadFromDisk. The load.go RemoveConfigField comment is corrected to explain that Providers.Del keeps in-memory state consistent, not reload magic. Also fixes an aliasing bug where append could mutate the package-level defaultContextPaths slice by cloning it before prepending.

commit81170ce5d33280947e889ba8afb93a057d26b801 authorKieran Klukas <kieran@dunkirk.sh> dateFri 29 May 2026, 14:38 (5 months ago) parent5b5f7b6
4 files changed+42−33
internal/agent/coordinator.go+1−1 internal/config/load.go+10−8 internal/config/store.go+28−15 internal/config/store_test.go+3−9
internal/agent/coordinator.go+1−1
@@ -260,7 +260,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
260260 }
261261 beforeLoaded := c.skillTracker.LoadedNames()
262262 var result *fantasy.AgentResult
263− var originalErr error = c.runWithUnauthorizedRetry(ctx, providerCfg, func() error {
263+ originalErr := c.runWithUnauthorizedRetry(ctx, providerCfg, func() error {
264264 var err error
265265 result, err = run()
266266 return err
internal/config/load.go+10−8
@@ -108,10 +108,10 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
108108 valueResolver := NewShellVariableResolver(env)
109109 store.resolver = valueResolver
110110
111− // Disable auto-reload during initial load to prevent nested calls from
112− // config-modifying operations inside configureProviders.
113− store.autoReloadDisabled = true
114− defer func() { store.autoReloadDisabled = false }()
111+ // Hold reloadMu during initial load to prevent configureProviders
112+ // from triggering auto-reload via RemoveConfigField.
113+ store.reloadMu.Lock()
114+ defer store.reloadMu.Unlock()
115115
116116 if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
117117 return nil, fmt.Errorf("failed to configure providers: %w", err)
@@ -266,9 +266,11 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
266266 switch {
267267 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
268268 // Claude Code subscription is not supported anymore. Remove to show onboarding.
269− if !store.reloadInProgress {
270− store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
271− }
269+ // RemoveConfigField persists the deletion to disk. The in-memory
270+ // state is kept consistent by the Providers.Del call below; any
271+ // concurrent reload that races with this write will also see the
272+ // removal because it re-reads from disk.
273+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
272274 c.Providers.Del(string(p.ID))
273275 continue
274276 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -445,7 +447,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
445447 c.applyLSPDefaults()
446448
447449 // Add the default context paths if they are not already present
448− c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
450+ c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
449451 slices.Sort(c.Options.ContextPaths)
450452 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
451453
internal/config/store.go+28−15
@@ -50,6 +50,10 @@ type RuntimeOverrides struct {
5050// RemoveConfigField, RefreshOAuthToken) to prevent both in-process
5151// goroutine races and, together with the shared lock.File, cross-process
5252// races on the config file.
53+//
54+// reloadMu serialises ReloadFromDisk calls to prevent concurrent reloads
55+// from racing on store fields. autoReload uses TryLock on reloadMu to
56+// skip redundant reloads when one is already in progress.
5357type ConfigStore struct {
5458 config *Config
5559 workingDir string
@@ -61,10 +65,9 @@ type ConfigStore struct {
6165 overrides RuntimeOverrides
6266 trackedConfigPaths []string // unique, normalized config file paths
6367 snapshots map[string]fileSnapshot // path -> snapshot at last capture
64− autoReloadDisabled bool // set during load/reload to prevent re-entrancy
65− reloadInProgress bool // set during reload to avoid disk writes mid-reload
6668
67− mu sync.Mutex
69+ mu sync.Mutex // serialises config file writes
70+ reloadMu sync.Mutex // serialises ReloadFromDisk calls
6871}
6972
7073// Config returns the pure-data config struct (read-only after load).
@@ -720,19 +723,18 @@ func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
720723// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
721724// config atomically. It rebuilds the staleness snapshot after successful reload.
722725// On failure, the store state is rolled back to its previous state.
726+// Concurrent calls are serialised via reloadMu.
723727func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
724728 if s.workingDir == "" {
725729 return fmt.Errorf("cannot reload: working directory not set")
726730 }
731+ s.reloadMu.Lock()
732+ defer s.reloadMu.Unlock()
733+ return s.reloadFromDiskLocked(ctx)
734+}
727735
728− // Disable auto-reload during reload to prevent nested/re-entrant calls.
729− s.autoReloadDisabled = true
730− s.reloadInProgress = true
731− defer func() {
732− s.autoReloadDisabled = false
733− s.reloadInProgress = false
734− }()
735−
736+// reloadFromDiskLocked performs the actual reload. Caller must hold reloadMu.
737+func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
736738 // Migrate deprecated disable_notifications before reloading config.
737739 migrateDisableNotifications()
738740
@@ -835,11 +837,22 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
835837// disabled during load/reload flows, or when working directory is not set
836838// (e.g., during testing). Only actual reload failures return an error.
837839func (s *ConfigStore) autoReload(ctx context.Context) error {
838− if s.autoReloadDisabled {
839− return nil // Expected skip: already in load/reload flow
840− }
841840 if s.workingDir == "" {
842841 return nil // Expected skip: working directory not set
843842 }
844− return s.ReloadFromDisk(ctx)
843+ // Skip if a reload is already in progress. This handles both
844+ // concurrent auto-reloads after parallel writes and re-entrant
845+ // calls from configureProviders during a reload.
846+ //
847+ // Note: if a write completes after the in-progress reload has
848+ // already read the config file, that write won't be reflected in
849+ // memory until the next reload. This is acceptable because writes
850+ // are rare and the next user action or file-watch tick will pick
851+ // up the change. Callers that need guaranteed fresh state after a
852+ // write should call ReloadFromDisk explicitly.
853+ if !s.reloadMu.TryLock() {
854+ return nil
855+ }
856+ defer s.reloadMu.Unlock()
857+ return s.reloadFromDiskLocked(ctx)
845858}
internal/config/store_test.go+3−9
@@ -497,27 +497,21 @@ func TestAutoReloadDisabledDuringReload(t *testing.T) {
497497 }`
498498 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
499499
500− // Load will trigger configureProviders which removes anthropic OAuth config
501− // This should NOT cause infinite recursion thanks to autoReloadDisabled guard
500+ // Load will trigger configureProviders which removes anthropic OAuth config.
501+ // This should NOT cause infinite recursion — reloadMu prevents re-entrant reloads.
502502 store, err := Load(dir, dir, false)
503503 require.NoError(t, err)
504504
505− // Verify the store loaded successfully and autoReloadDisabled was unset
506− require.False(t, store.autoReloadDisabled)
507−
508505 // Capture snapshot and verify reload also works without recursion
509506 store.globalDataPath = configPath
510507 store.CaptureStalenessSnapshot([]string{configPath})
511508
512− // Modify file and reload - this should work without re-entrancy issues
509+ // Modify file and reload — this should work without re-entrancy issues
513510 time.Sleep(10 * time.Millisecond)
514511 require.NoError(t, os.WriteFile(configPath, []byte(`{"options": {"debug": true}}`), 0o600))
515512
516513 err = store.ReloadFromDisk(context.Background())
517514 require.NoError(t, err)
518−
519− // Verify reload completed successfully
520− require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
521515}
522516
523517// TestSetConfigFields_AutoReloadsAtomically verifies that SetConfigFields writes