@@ -50,6 +50,10 @@ type RuntimeOverrides struct {
50// RemoveConfigField, RefreshOAuthToken) to prevent both in-process
50// RemoveConfigField, RefreshOAuthToken) to prevent both in-process
51// goroutine races and, together with the shared lock.File, cross-process
51// goroutine races and, together with the shared lock.File, cross-process
52// races on the config file.
52// races on the config file.
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.
53type ConfigStore struct {
57type ConfigStore struct {
54 config *Config
58 config *Config
55 workingDir string
59 workingDir string
@@ -61,10 +65,9 @@ type ConfigStore struct {
61 overrides RuntimeOverrides
65 overrides RuntimeOverrides
62 trackedConfigPaths []string // unique, normalized config file paths
66 trackedConfigPaths []string // unique, normalized config file paths
63 snapshots map[string]fileSnapshot // path -> snapshot at last capture
67 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
67− mu sync.Mutex
69+ mu sync.Mutex // serialises config file writes
70+ reloadMu sync.Mutex // serialises ReloadFromDisk calls
70// Config returns the pure-data config struct (read-only after load).
73// Config returns the pure-data config struct (read-only after load).
@@ -720,19 +723,18 @@ func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
720// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
723// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
721// config atomically. It rebuilds the staleness snapshot after successful reload.
724// config atomically. It rebuilds the staleness snapshot after successful reload.
722// On failure, the store state is rolled back to its previous state.
725// On failure, the store state is rolled back to its previous state.
726+// Concurrent calls are serialised via reloadMu.
723func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
727func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
724 if s.workingDir == "" {
728 if s.workingDir == "" {
725 return fmt.Errorf("cannot reload: working directory not set")
729 return fmt.Errorf("cannot reload: working directory not set")
732+ defer s.reloadMu.Unlock()
733+ return s.reloadFromDiskLocked(ctx)
728− // Disable auto-reload during reload to prevent nested/re-entrant calls.
736+// reloadFromDiskLocked performs the actual reload. Caller must hold reloadMu.
729− s.autoReloadDisabled = true
737+func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
730− s.reloadInProgress = true
732− s.autoReloadDisabled = false
733− s.reloadInProgress = false
736 // Migrate deprecated disable_notifications before reloading config.
738 // Migrate deprecated disable_notifications before reloading config.
737 migrateDisableNotifications()
739 migrateDisableNotifications()
@@ -835,11 +837,22 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
835// disabled during load/reload flows, or when working directory is not set
837// disabled during load/reload flows, or when working directory is not set
836// (e.g., during testing). Only actual reload failures return an error.
838// (e.g., during testing). Only actual reload failures return an error.
837func (s *ConfigStore) autoReload(ctx context.Context) error {
839func (s *ConfigStore) autoReload(ctx context.Context) error {
838− if s.autoReloadDisabled {
839− return nil // Expected skip: already in load/reload flow
841 if s.workingDir == "" {
840 if s.workingDir == "" {
842 return nil // Expected skip: working directory not set
841 return nil // Expected skip: working directory not set
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.
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
844− return s.ReloadFromDisk(ctx)
852+ // write should call ReloadFromDisk explicitly.
853+ if !s.reloadMu.TryLock() {
856+ defer s.reloadMu.Unlock()
857+ return s.reloadFromDiskLocked(ctx)