@@ -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}