on main help sign in
fix(config): make concurrent config access race-free via copy-on-write

Reading the active config raced with writes that mutated it: Config() handed out a pointer with no synchronization while reloads swapped it and typed setters (compact mode, preferred model, docker MCP, ...) mutated its fields in place. The race detector flagged both during concurrent startup and settings changes. Config is now published copy-on-write. Reads are guarded so they always see a fully constructed snapshot, and every mutation clones the current config, changes the clone, and atomically swaps it in. Because published configs are never mutated in place, a reader holding an older pointer keeps reading a consistent snapshot instead of racing a writer. A single write lock now serializes all config-producing operations (typed setters and reloads), replacing the narrower reload-only lock, so two writers can no longer lose each other's updates. This keeps model selection fast — it still skips the expensive full reload — while closing the remaining data races.

commitb10f890f55b1c00969b8e59a6828b6f405103b5a authorKieran Klukas <kieran@dunkirk.sh> dateMon 22 Jun 2026, 16:58 (4 months ago) parentf5b996b
13 files changed+436−326
internal/agent/agenttest/coordinator.go+2−2 internal/app/app.go+5−5 internal/backend/config.go+2−2 internal/config/clone_test.go+46 internal/config/config.go+39 internal/config/docker_mcp.go+23−16 internal/config/docker_mcp_test.go+9−7 internal/config/load.go+3−3 internal/config/recent_models_test.go+67−170 internal/config/scopeb_race_test.go+74 internal/config/store.go+163−118 internal/config/store_test.go+1−1 internal/workspace/app_workspace.go+2−2
internal/agent/agenttest/coordinator.go+2−2
@@ -55,8 +55,8 @@ func NewCoordinator(
5555 Models: []catwalk.Model{{ID: modelID, DefaultMaxTokens: 4096}},
5656 })
5757 selected := config.SelectedModel{Provider: providerID, Model: modelID}
58− cfg.Config().Models[config.SelectedModelTypeLarge] = selected
59− cfg.Config().Models[config.SelectedModelTypeSmall] = selected
58+ cfg.OverridePreferredModel(config.SelectedModelTypeLarge, selected)
59+ cfg.OverridePreferredModel(config.SelectedModelTypeSmall, selected)
6060 cfg.SetupAgents()
6161
6262 // Keep buildTools light: no sub-agent or agentic-fetch construction.
internal/app/app.go+5−5
@@ -433,10 +433,10 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
433433 }
434434 largeProviderID = found.provider
435435 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
436− app.config.Config().Models[config.SelectedModelTypeLarge] = config.SelectedModel{
436+ app.config.OverridePreferredModel(config.SelectedModelTypeLarge, config.SelectedModel{
437437 Provider: found.provider,
438438 Model: found.modelID,
439− }
439+ })
440440 }
441441
442442 // Override small model.
@@ -447,15 +447,15 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
447447 return err
448448 }
449449 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
450− app.config.Config().Models[config.SelectedModelTypeSmall] = config.SelectedModel{
450+ app.config.OverridePreferredModel(config.SelectedModelTypeSmall, config.SelectedModel{
451451 Provider: found.provider,
452452 Model: found.modelID,
453− }
453+ })
454454
455455 case largeModel != "":
456456 // No small model specified, but large model was - use provider's default.
457457 smallCfg := app.GetDefaultSmallModel(largeProviderID)
458− app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg
458+ app.config.OverridePreferredModel(config.SelectedModelTypeSmall, smallCfg)
459459 }
460460
461461 return app.AgentCoordinator.UpdateModels(ctx)
internal/backend/config.go+2−2
@@ -222,13 +222,13 @@ func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error
222222
223223 if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
224224 disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
225− delete(ws.Cfg.Config().MCP, config.DockerMCPName)
225+ ws.Cfg.RemoveDockerMCPInMemory()
226226 return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
227227 }
228228
229229 if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
230230 disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
231− delete(ws.Cfg.Config().MCP, config.DockerMCPName)
231+ ws.Cfg.RemoveDockerMCPInMemory()
232232 return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
233233 }
234234
internal/config/clone_test.gonew file+46
@@ -0,0 +1,46 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/csync"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// TestCloneForWrite_Isolation verifies that mutating a clone never reaches
11+// back into the original Config. This is the contract the store's
12+// copy-on-write mutators depend on for race-free publishing.
13+func TestCloneForWrite_Isolation(t *testing.T) {
14+ t.Parallel()
15+
16+ orig := &Config{
17+ Models: map[SelectedModelType]SelectedModel{
18+ SelectedModelTypeLarge: {Provider: "openai", Model: "gpt-4"},
19+ },
20+ RecentModels: map[SelectedModelType][]SelectedModel{
21+ SelectedModelTypeLarge: {{Provider: "openai", Model: "gpt-4"}},
22+ },
23+ MCP: MCPs{"a": {}},
24+ Providers: csync.NewMap[string, ProviderConfig](),
25+ Options: &Options{
26+ TUI: &TUIOptions{CompactMode: false},
27+ },
28+ }
29+
30+ clone := orig.cloneForWrite()
31+
32+ // Mutate every field the typed mutators touch.
33+ clone.Models[SelectedModelTypeLarge] = SelectedModel{Provider: "anthropic", Model: "claude"}
34+ clone.RecentModels[SelectedModelTypeLarge] = []SelectedModel{{Provider: "anthropic", Model: "claude"}}
35+ clone.MCP["b"] = MCPConfig{}
36+ clone.Options.TUI.CompactMode = true
37+ enabled := true
38+ clone.Options.TUI.Transparent = &enabled
39+
40+ // The original must be untouched.
41+ require.Equal(t, "openai", orig.Models[SelectedModelTypeLarge].Provider, "Models leaked")
42+ require.Equal(t, "openai", orig.RecentModels[SelectedModelTypeLarge][0].Provider, "RecentModels leaked")
43+ require.NotContains(t, orig.MCP, "b", "MCP leaked")
44+ require.False(t, orig.Options.TUI.CompactMode, "Options.TUI.CompactMode leaked")
45+ require.Nil(t, orig.Options.TUI.Transparent, "Options.TUI.Transparent leaked")
46+}
internal/config/config.go+39
@@ -619,6 +619,45 @@ type Config struct {
619619 Agents map[string]Agent `json:"-"`
620620}
621621
622+// cloneForWrite returns a copy of c that the store's typed field mutators
623+// may modify without racing readers of the currently published Config.
624+//
625+// Reads of a published Config take no lock beyond the pointer load, so a
626+// mutator must never write through the live pointer. Instead it clones,
627+// mutates the clone, and atomically swaps it in. The clone gives fresh
628+// copies of every field a typed mutator touches in place — Models,
629+// RecentModels, MCP, and Options (with its nested TUI pointer). Providers
630+// is a *csync.Map (internally synchronized) and is shared by reference;
631+// the remaining fields are immutable after load from the mutators'
632+// standpoint and are likewise shared.
633+func (c *Config) cloneForWrite() *Config {
634+ nc := *c
635+ nc.Models = maps.Clone(c.Models)
636+ nc.RecentModels = maps.Clone(c.RecentModels)
637+ nc.MCP = maps.Clone(c.MCP)
638+ if c.Options != nil {
639+ opts := *c.Options
640+ if c.Options.TUI != nil {
641+ tui := *c.Options.TUI
642+ opts.TUI = &tui
643+ }
644+ nc.Options = &opts
645+ }
646+ return &nc
647+}
648+
649+// ensureTUI returns c.Options.TUI, allocating Options and TUI as needed so
650+// callers can assign TUI fields without nil checks.
651+func (c *Config) ensureTUI() *TUIOptions {
652+ if c.Options == nil {
653+ c.Options = &Options{}
654+ }
655+ if c.Options.TUI == nil {
656+ c.Options.TUI = &TUIOptions{}
657+ }
658+ return c.Options.TUI
659+}
660+
622661func (c *Config) EnabledProviders() []ProviderConfig {
623662 var enabled []ProviderConfig
624663 for p := range c.Providers.Seq() {
internal/config/docker_mcp.go+23−16
@@ -88,10 +88,13 @@ func (s *ConfigStore) PrepareDockerMCPConfig() (MCPConfig, error) {
8888 }
8989
9090 mcpConfig := DockerMCPConfig()
91− if s.config.MCP == nil {
92− s.config.MCP = make(map[string]MCPConfig)
93− }
94− s.config.MCP[DockerMCPName] = mcpConfig
91+ // In-memory only; persistence happens in PersistDockerMCPConfig.
92+ s.mutateInMemory(func(c *Config) {
93+ if c.MCP == nil {
94+ c.MCP = make(map[string]MCPConfig)
95+ }
96+ c.MCP[DockerMCPName] = mcpConfig
97+ })
9598 return mcpConfig, nil
9699}
97100
@@ -118,17 +121,21 @@ func (s *ConfigStore) EnableDockerMCP() error {
118121
119122// DisableDockerMCP removes Docker MCP configuration and persists the change.
120123func (s *ConfigStore) DisableDockerMCP() error {
121− if s.config.MCP == nil {
122− return nil
123− }
124−
125− // Remove from in-memory config.
126− delete(s.config.MCP, DockerMCPName)
127−
128− // Persist the updated MCP map to the config file.
129− if err := s.SetConfigField(ScopeGlobal, "mcp", s.config.MCP); err != nil {
130− return fmt.Errorf("failed to persist docker mcp removal: %w", err)
131− }
124+ return s.update(ScopeGlobal, func(c *Config) map[string]any {
125+ if c.MCP == nil {
126+ return nil
127+ }
128+ delete(c.MCP, DockerMCPName)
129+ return map[string]any{"mcp": c.MCP}
130+ })
131+}
132132
133− return nil
133+// RemoveDockerMCPInMemory removes the Docker MCP entry from the in-memory
134+// config via copy-on-write, without persisting to disk. It rolls back a
135+// staged PrepareDockerMCPConfig when starting or persisting the server
136+// fails, so callers must not mutate Config().MCP directly.
137+func (s *ConfigStore) RemoveDockerMCPInMemory() {
138+ s.mutateInMemory(func(c *Config) {
139+ delete(c.MCP, DockerMCPName)
140+ })
134141}
internal/config/docker_mcp_test.go+9−7
@@ -75,9 +75,10 @@ func TestEnableDockerMCP(t *testing.T) {
7575 err := store.EnableDockerMCP()
7676 require.NoError(t, err)
7777
78− // Check in-memory config.
79− require.True(t, cfg.IsDockerMCPEnabled())
80− mcpConfig, exists := cfg.MCP[DockerMCPName]
78+ // Check in-memory config via the store (copy-on-write publishes
79+ // a new Config; the captured cfg pointer stays unchanged).
80+ require.True(t, store.Config().IsDockerMCPEnabled())
81+ mcpConfig, exists := store.Config().MCP[DockerMCPName]
8182 require.True(t, exists)
8283 require.Equal(t, MCPStdio, mcpConfig.Type)
8384 require.Equal(t, "docker", mcpConfig.Command)
@@ -145,9 +146,10 @@ func TestDisableDockerMCP(t *testing.T) {
145146 err := store.DisableDockerMCP()
146147 require.NoError(t, err)
147148
148− // Check in-memory config.
149− require.False(t, cfg.IsDockerMCPEnabled())
150− _, exists := cfg.MCP[DockerMCPName]
149+ // Check in-memory config via the store (copy-on-write publishes a
150+ // new Config; the original cfg pointer is intentionally unchanged).
151+ require.False(t, store.Config().IsDockerMCPEnabled())
152+ _, exists := store.Config().MCP[DockerMCPName]
151153 require.False(t, exists)
152154 })
153155
@@ -189,5 +191,5 @@ func TestEnableDockerMCPWithRealDockerWhenAvailable(t *testing.T) {
189191
190192 err := store.EnableDockerMCP()
191193 require.NoError(t, err)
192− require.True(t, cfg.IsDockerMCPEnabled())
194+ require.True(t, store.Config().IsDockerMCPEnabled())
193195}
internal/config/load.go+3−3
@@ -111,10 +111,10 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
111111 valueResolver := NewShellVariableResolver(env)
112112 store.resolver = valueResolver
113113
114− // Hold reloadMu during initial load to prevent configureProviders
114+ // Hold writeMu during initial load to prevent configureProviders
115115 // from triggering auto-reload via RemoveConfigField.
116− store.reloadMu.Lock()
117− defer store.reloadMu.Unlock()
116+ store.writeMu.Lock()
117+ defer store.writeMu.Unlock()
118118
119119 if err := cfg.configureProviders(context.Background(), store, env, valueResolver, store.knownProviders); err != nil {
120120 return nil, fmt.Errorf("failed to configure providers: %w", err)
internal/config/recent_models_test.go+67−170
@@ -39,158 +39,76 @@ func testStoreWithPath(cfg *Config, dir string) *ConfigStore {
3939 }
4040}
4141
42−func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
43− t.Parallel()
44−
45− dir := t.TempDir()
46− cfg := &Config{}
47− cfg.setDefaults(dir, "")
48− store := testStoreWithPath(cfg, dir)
49−
50− err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
51− require.NoError(t, err)
52−
53− // in-memory state
54− require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
55− require.Equal(t, "openai", cfg.RecentModels[SelectedModelTypeLarge][0].Provider)
56− require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
57−
58− // persisted state
59− rm := readRecentModels(t, store.globalDataPath)
60− large, ok := rm[string(SelectedModelTypeLarge)].([]any)
61− require.True(t, ok)
62− require.Len(t, large, 1)
63− item, ok := large[0].(map[string]any)
64− require.True(t, ok)
65− require.Equal(t, "openai", item["provider"])
66− require.Equal(t, "gpt-4o", item["model"])
42+// configWithRecents builds a Config seeded with the given recent models for
43+// the large type, for exercising the pure nextRecentModels helper.
44+func configWithRecents(recents ...SelectedModel) *Config {
45+ return &Config{
46+ RecentModels: map[SelectedModelType][]SelectedModel{
47+ SelectedModelTypeLarge: recents,
48+ },
49+ }
6750}
6851
69−func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
52+func TestNextRecentModels_AddsToFront(t *testing.T) {
7053 t.Parallel()
7154
72− dir := t.TempDir()
73− cfg := &Config{}
74− cfg.setDefaults(dir, "")
75− store := testStoreWithPath(cfg, dir)
55+ cfg := configWithRecents()
56+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
57+ require.True(t, changed)
58+ require.Equal(t, []SelectedModel{{Provider: "openai", Model: "gpt-4o"}}, updated)
59+}
7660
77− // Add two entries
78− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
79− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
80− // Re-add first; should move to front and not duplicate
81− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
61+func TestNextRecentModels_DedupeAndMoveToFront(t *testing.T) {
62+ t.Parallel()
8263
83− got := cfg.RecentModels[SelectedModelTypeLarge]
84− require.Len(t, got, 2)
85− require.Equal(t, SelectedModel{Provider: "openai", Model: "gpt-4o"}, got[0])
86− require.Equal(t, SelectedModel{Provider: "anthropic", Model: "claude"}, got[1])
64+ cfg := configWithRecents(
65+ SelectedModel{Provider: "anthropic", Model: "claude"},
66+ SelectedModel{Provider: "openai", Model: "gpt-4o"},
67+ )
68+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
69+ require.True(t, changed)
70+ require.Equal(t, []SelectedModel{
71+ {Provider: "openai", Model: "gpt-4o"},
72+ {Provider: "anthropic", Model: "claude"},
73+ }, updated)
8774}
8875
89−func TestRecordRecentModel_TrimsToMax(t *testing.T) {
76+func TestNextRecentModels_TrimsToMax(t *testing.T) {
9077 t.Parallel()
9178
92− dir := t.TempDir()
93− cfg := &Config{}
94− cfg.setDefaults(dir, "")
95− store := testStoreWithPath(cfg, dir)
96−
97− // Insert 6 unique models; max is 5
98− entries := []SelectedModel{
99− {Provider: "p1", Model: "m1"},
100− {Provider: "p2", Model: "m2"},
101− {Provider: "p3", Model: "m3"},
102− {Provider: "p4", Model: "m4"},
103− {Provider: "p5", Model: "m5"},
104− {Provider: "p6", Model: "m6"},
79+ var seed []SelectedModel
80+ for _, id := range []string{"m5", "m4", "m3", "m2", "m1"} {
81+ seed = append(seed, SelectedModel{Provider: "p", Model: id})
10582 }
106− for _, e := range entries {
107− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e))
108− }
109−
110− // in-memory state
111− got := cfg.RecentModels[SelectedModelTypeLarge]
112− require.Len(t, got, 5)
113− // Newest first, capped at 5: p6..p2
114− require.Equal(t, SelectedModel{Provider: "p6", Model: "m6"}, got[0])
115− require.Equal(t, SelectedModel{Provider: "p5", Model: "m5"}, got[1])
116− require.Equal(t, SelectedModel{Provider: "p4", Model: "m4"}, got[2])
117− require.Equal(t, SelectedModel{Provider: "p3", Model: "m3"}, got[3])
118− require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
83+ cfg := configWithRecents(seed...)
11984
120− // persisted state: verify trimmed to 5 and newest-first order
121− rm := readRecentModels(t, store.globalDataPath)
122− large, ok := rm[string(SelectedModelTypeLarge)].([]any)
123− require.True(t, ok)
124− require.Len(t, large, 5)
125− // Build provider:model IDs and verify order
126− var ids []string
127− for _, v := range large {
128− m := v.(map[string]any)
129− ids = append(ids, m["provider"].(string)+":"+m["model"].(string))
130− }
131− require.Equal(t, []string{"p6:m6", "p5:m5", "p4:m4", "p3:m3", "p2:m2"}, ids)
85+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: "m6"})
86+ require.True(t, changed)
87+ require.Len(t, updated, maxRecentModelsPerType)
88+ require.Equal(t, SelectedModel{Provider: "p", Model: "m6"}, updated[0])
89+ require.Equal(t, SelectedModel{Provider: "p", Model: "m2"}, updated[maxRecentModelsPerType-1])
13290}
13391
134−func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
92+func TestNextRecentModels_SkipsEmptyValues(t *testing.T) {
13593 t.Parallel()
13694
137− dir := t.TempDir()
138− cfg := &Config{}
139− cfg.setDefaults(dir, "")
140− store := testStoreWithPath(cfg, dir)
141−
142− // Missing provider
143− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
144− // Missing model
145− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
146−
147− _, ok := cfg.RecentModels[SelectedModelTypeLarge]
148− // Map may be initialized, but should have no entries
149− if ok {
150− require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
151− }
152− // No file should be written (stat via fs.FS)
153− baseDir := filepath.Dir(store.globalDataPath)
154− fileName := filepath.Base(store.globalDataPath)
155− _, err := fs.Stat(os.DirFS(baseDir), fileName)
156− require.True(t, os.IsNotExist(err))
95+ cfg := configWithRecents()
96+ _, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})
97+ require.False(t, changed)
98+ _, changed = nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})
99+ require.False(t, changed)
157100}
158101
159−func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
102+func TestNextRecentModels_NoChangeWhenAlreadyFront(t *testing.T) {
160103 t.Parallel()
161104
162− dir := t.TempDir()
163− cfg := &Config{}
164− cfg.setDefaults(dir, "")
165− store := testStoreWithPath(cfg, dir)
166−
167105 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
168− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
169−
170− baseDir := filepath.Dir(store.globalDataPath)
171− fileName := filepath.Base(store.globalDataPath)
172− before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
173− require.NoError(t, err)
174−
175− // Get file ModTime to verify no write occurs
176− stBefore, err := fs.Stat(os.DirFS(baseDir), fileName)
177− require.NoError(t, err)
178− beforeMod := stBefore.ModTime()
179−
180− // Re-record same entry should be a no-op (no write)
181− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
182−
183− after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
184− require.NoError(t, err)
185− require.Equal(t, string(before), string(after))
186−
187− // Verify ModTime unchanged to ensure truly no write occurred
188− stAfter, err := fs.Stat(os.DirFS(baseDir), fileName)
189− require.NoError(t, err)
190− require.True(t, stAfter.ModTime().Equal(beforeMod), "file ModTime should not change on noop")
106+ cfg := configWithRecents(entry)
107+ _, changed := nextRecentModels(cfg, SelectedModelTypeLarge, entry)
108+ require.False(t, changed)
191109}
192110
193−func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
111+func TestUpdatePreferredModel_PersistsModelAndRecents(t *testing.T) {
194112 t.Parallel()
195113
196114 dir := t.TempDir()
@@ -199,20 +117,24 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
199117 store := testStoreWithPath(cfg, dir)
200118
201119 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
202− require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel))
120+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, sel))
203121
204− // in-memory
205− require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
206− require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
122+ // in-memory state (read through the store; copy-on-write publishes a
123+ // new Config, so the seed cfg pointer is intentionally unchanged).
124+ require.Equal(t, sel, store.Config().Models[SelectedModelTypeLarge])
125+ require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 1)
207126
208− // persisted (read via fs.FS)
127+ // persisted state
209128 rm := readRecentModels(t, store.globalDataPath)
210− small, ok := rm[string(SelectedModelTypeSmall)].([]any)
129+ large, ok := rm[string(SelectedModelTypeLarge)].([]any)
211130 require.True(t, ok)
212− require.Len(t, small, 1)
131+ require.Len(t, large, 1)
132+ item := large[0].(map[string]any)
133+ require.Equal(t, "openai", item["provider"])
134+ require.Equal(t, "gpt-4o", item["model"])
213135}
214136
215−func TestRecordRecentModel_TypeIsolation(t *testing.T) {
137+func TestUpdatePreferredModel_TypeIsolation(t *testing.T) {
216138 t.Parallel()
217139
218140 dir := t.TempDir()
@@ -220,42 +142,17 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
220142 cfg.setDefaults(dir, "")
221143 store := testStoreWithPath(cfg, dir)
222144
223− // Add models to both large and small types
224145 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
225146 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
147+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
148+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
226149
227− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
228− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
229−
230− // in-memory: verify types maintain separate histories
231− require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
232− require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
233− require.Equal(t, largeModel, cfg.RecentModels[SelectedModelTypeLarge][0])
234− require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235−
236− // Add another to large, verify small unchanged
150+ // Adding to large leaves small untouched.
237151 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
238− require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
239−
240− require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
241− require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
242− require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
243−
244− // persisted state: verify both types exist with correct lengths and contents
245− rm := readRecentModels(t, store.globalDataPath)
152+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
246153
247− large, ok := rm[string(SelectedModelTypeLarge)].([]any)
248− require.True(t, ok)
249− require.Len(t, large, 2)
250− // Verify newest first for large type
251− require.Equal(t, "google", large[0].(map[string]any)["provider"])
252− require.Equal(t, "gemini", large[0].(map[string]any)["model"])
253− require.Equal(t, "openai", large[1].(map[string]any)["provider"])
254− require.Equal(t, "gpt-4o", large[1].(map[string]any)["model"])
255−
256− small, ok := rm[string(SelectedModelTypeSmall)].([]any)
257− require.True(t, ok)
258− require.Len(t, small, 1)
259− require.Equal(t, "anthropic", small[0].(map[string]any)["provider"])
260− require.Equal(t, "claude", small[0].(map[string]any)["model"])
154+ require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 2)
155+ require.Equal(t, anotherLarge, store.Config().RecentModels[SelectedModelTypeLarge][0])
156+ require.Len(t, store.Config().RecentModels[SelectedModelTypeSmall], 1)
157+ require.Equal(t, smallModel, store.Config().RecentModels[SelectedModelTypeSmall][0])
261158}
internal/config/scopeb_race_test.gonew file+74
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "sync"
7+ "testing"
8+)
9+
10+// TestScopeB_InPlaceMutationRace probes whether the store's in-place field
11+// mutators (e.g. SetCompactMode) race with concurrent Config() readers that
12+// walk the same field. This is the "Scope B" race left open after the
13+// pointer-swap fix. Run with -race.
14+// TestScopeB_InPlaceMutationRace verifies that the store's typed field
15+// mutators (e.g. SetCompactMode) no longer race concurrent Config()
16+// readers walking the same field. Copy-on-write publishing means the
17+// mutator swaps in a fresh Config rather than writing through the live
18+// pointer, so a reader always sees an immutable snapshot. Run with -race.
19+func TestScopeB_InPlaceMutationRace(t *testing.T) {
20+ dir := t.TempDir()
21+ configPath := filepath.Join(dir, "crush.json")
22+
23+ t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
24+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
25+ resetProviderState()
26+ t.Cleanup(resetProviderState)
27+
28+ cfg := `{
29+ "models": {"large": {"provider": "openai", "model": "gpt-4"}},
30+ "providers": {"openai": {"api_key": "k", "models": [{"id": "gpt-4", "name": "GPT-4"}]}},
31+ "options": {"tui": {"compact_mode": false}}
32+ }`
33+ if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
34+ t.Fatal(err)
35+ }
36+
37+ store, err := Load(dir, dir, false)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ store.globalDataPath = configPath
42+
43+ var wg sync.WaitGroup
44+ stop := make(chan struct{})
45+
46+ // Reader: walks Options.TUI.CompactMode, the field SetCompactMode writes.
47+ wg.Add(1)
48+ go func() {
49+ defer wg.Done()
50+ for {
51+ select {
52+ case <-stop:
53+ return
54+ default:
55+ c := store.Config()
56+ if c != nil && c.Options != nil {
57+ _ = c.Options.TUI.CompactMode
58+ }
59+ }
60+ }
61+ }()
62+
63+ // Writer: flips compact mode, mutating Options in place.
64+ wg.Add(1)
65+ go func() {
66+ defer wg.Done()
67+ for i := 0; i < 50; i++ {
68+ _ = store.SetCompactMode(ScopeGlobal, i%2 == 0)
69+ }
70+ close(stop)
71+ }()
72+
73+ wg.Wait()
74+}
internal/config/store.go+163−118
@@ -51,9 +51,14 @@ type RuntimeOverrides struct {
5151// goroutine races and, together with the shared lock.File, cross-process
5252// races on the config file.
5353//
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.
54+// writeMu serialises every operation that produces a new in-memory Config:
55+// the typed copy-on-write mutators (SetCompactMode, UpdatePreferredModel,
56+// ...) and ReloadFromDisk. Typed mutators take Lock; autoReload takes
57+// TryLock so a write triggered re-entrantly during a reload (e.g.
58+// configureProviders calling RemoveConfigField) skips the nested reload
59+// instead of deadlocking. This is what lets published Configs be treated
60+// as immutable: a mutator clones, mutates the clone, and swaps it in under
61+// writeMu rather than mutating the live Config in place.
5762type ConfigStore struct {
5863 config *Config
5964 workingDir string
@@ -66,15 +71,39 @@ type ConfigStore struct {
6671 trackedConfigPaths []string // unique, normalized config file paths
6772 snapshots map[string]fileSnapshot // path -> snapshot at last capture
6873
69− mu sync.Mutex // serialises config file writes
70− reloadMu sync.Mutex // serialises ReloadFromDisk calls
74+ // configMu guards the config pointer field against concurrent
75+ // readers (Config) and the writeMu-serialised swap (setConfig). It
76+ // protects the pointer word only; the pointed-to Config is treated
77+ // as immutable once published, since both reloads and typed mutators
78+ // build a fresh Config rather than mutating the live one.
79+ configMu sync.RWMutex
80+
81+ mu sync.Mutex // serialises config file writes
82+ writeMu sync.Mutex // serialises in-memory config production (mutators + reload)
7183}
7284
7385// Config returns the pure-data config struct (read-only after load).
86+//
87+// The pointer read is guarded by configMu so it can never tear against
88+// the reload swap in reloadFromDiskLocked. Reloads build a brand-new
89+// Config and swap it in rather than mutating the live one, so holding the
90+// returned pointer stays safe even across a concurrent reload — the reader
91+// keeps reading its (now immutable) snapshot.
7492func (s *ConfigStore) Config() *Config {
93+ s.configMu.RLock()
94+ defer s.configMu.RUnlock()
7595 return s.config
7696}
7797
98+// setConfig atomically swaps the active config pointer under configMu.
99+// Used by the reload path; in-place field mutators leave the pointer
100+// untouched and run under mu instead.
101+func (s *ConfigStore) setConfig(cfg *Config) {
102+ s.configMu.Lock()
103+ defer s.configMu.Unlock()
104+ s.config = cfg
105+}
106+
78107// WorkingDir returns the current working directory.
79108func (s *ConfigStore) WorkingDir() string {
80109 return s.workingDir
@@ -100,7 +129,7 @@ func (s *ConfigStore) KnownProviders() []catwalk.Provider {
100129
101130// SetupAgents configures the coder and task agents on the config.
102131func (s *ConfigStore) SetupAgents() {
103− s.config.SetupAgents()
132+ s.Config().SetupAgents()
104133}
105134
106135// Overrides returns the runtime overrides for this store.
@@ -212,25 +241,36 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
212241 return s.SetConfigFields(scope, map[string]any{key: value})
213242}
214243
215−// SetConfigFields sets multiple key/value pairs in the config file for the given
216−// scope in a single write. After a successful write, it automatically reloads
217−// config to keep in-memory state fresh. This is preferred over multiple
218−// SetConfigField calls when writing several fields atomically to avoid
219−// intermediate reloads with partial state.
244+// SetConfigFields sets multiple key/value pairs in the config file for the
245+// given scope in a single write, then reloads in-memory state from disk.
246+//
247+// Use this for arbitrary external edits where the in-memory effect of the
248+// change is not known ahead of time. The typed mutators (which know exactly
249+// what changed) go through update instead and skip the reload.
220250//
221251// The write is protected by an in-process mutex and a cross-process flock
222252// to prevent races between concurrent writers in different processes.
223253func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
224− return s.setConfigFields(scope, kv, true)
254+ if err := s.writeConfigFields(scope, kv); err != nil {
255+ return err
256+ }
257+ // Auto-reload to keep in-memory state fresh after config edits.
258+ // We use context.Background() since this is an internal operation that
259+ // shouldn't be cancelled by user context.
260+ if err := s.autoReload(context.Background()); err != nil {
261+ // Log warning but don't fail the write - disk is already updated.
262+ slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
263+ }
264+ return nil
225265}
226266
227−// setConfigFields performs the write and, when reload is true, refreshes
228−// in-memory state from disk. Callers that have already updated the relevant
229−// in-memory state themselves can pass reload=false to skip the expensive
230−// full reparse (which rebuilds the provider catalog and agents). The disk
231−// is the source of truth for other processes either way; skipping reload
232−// only avoids re-deriving state this process already holds.
233−func (s *ConfigStore) setConfigFields(scope Scope, kv map[string]any, reload bool) error {
267+// writeConfigFields persists key/value pairs to the config file. It does not
268+// touch in-memory config state or the staleness snapshot: callers either
269+// reload (SetConfigFields, whose reload recaptures the snapshot) or have
270+// already published an updated clone and capture the snapshot themselves
271+// (update). Both of those run under writeMu, which is what keeps the
272+// snapshot map free of concurrent writers.
273+func (s *ConfigStore) writeConfigFields(scope Scope, kv map[string]any) error {
234274 // Sort keys for deterministic output regardless of map iteration
235275 // order. This also ensures consistent results when callers pass
236276 // overlapping JSONPath keys (e.g. "a" and "a.b").
@@ -240,42 +280,71 @@ func (s *ConfigStore) setConfigFields(scope Scope, kv map[string]any, reload boo
240280 }
241281 slices.Sort(keys)
242282
243− err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
283+ return s.atomicWrite(scope, func(data []byte) ([]byte, error) {
244284 v := string(data)
245285 for _, key := range keys {
246286 var sErr error
247− v, sErr = sjson.Set(v, key, kv[key])
248− if sErr != nil {
287+ if v, sErr = sjson.Set(v, key, kv[key]); sErr != nil {
249288 return nil, fmt.Errorf("failed to set config field %s: %w", key, sErr)
250289 }
251290 }
252291 return []byte(v), nil
253292 })
254− if err != nil {
255− return err
256− }
293+}
257294
258− if !reload {
259− // The caller has already updated in-memory state; just refresh
260− // the staleness snapshot so the file watcher does not treat our
261− // own write as an external change.
262− if path, perr := s.configPath(scope); perr == nil {
263− s.captureStalenessSnapshot(append(slices.Clone(s.loadedPaths), path))
264− }
295+// mutateInMemory applies a copy-on-write change to the config without
296+// persisting. Under writeMu it clones the live config, lets mutate edit the
297+// clone, and publishes it. This is the single primitive every in-memory
298+// config change goes through, so a published Config is never mutated in
299+// place and readers always see a consistent snapshot.
300+func (s *ConfigStore) mutateInMemory(mutate func(*Config)) {
301+ s.writeMu.Lock()
302+ defer s.writeMu.Unlock()
303+
304+ nc := s.Config().cloneForWrite()
305+ mutate(nc)
306+ s.setConfig(nc)
307+}
308+
309+// update applies a copy-on-write change and persists the reported fields.
310+// mutate edits the clone and returns the JSON-path fields to write to disk;
311+// because the clone already reflects the change, no reload is needed.
312+// Returning an empty map publishes the clone without a disk write.
313+func (s *ConfigStore) update(scope Scope, mutate func(*Config) map[string]any) error {
314+ s.writeMu.Lock()
315+ defer s.writeMu.Unlock()
316+
317+ nc := s.Config().cloneForWrite()
318+ fields := mutate(nc)
319+ s.setConfig(nc)
320+ if len(fields) == 0 {
265321 return nil
266322 }
267−
268− // Auto-reload to keep in-memory state fresh after config edits.
269− // We use context.Background() since this is an internal operation that
270− // shouldn't be cancelled by user context.
271− if err := s.autoReload(context.Background()); err != nil {
272− // Log warning but don't fail the write - disk is already updated.
273− slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
323+ if err := s.writeConfigFields(scope, fields); err != nil {
324+ return err
325+ }
326+ // Refresh the staleness snapshot so the file watcher does not treat
327+ // our own write as an external change. Safe to touch the snapshot map
328+ // here because we hold writeMu.
329+ if path, err := s.configPath(scope); err == nil {
330+ s.captureStalenessSnapshot(append(slices.Clone(s.loadedPaths), path))
274331 }
275−
276332 return nil
277333}
278334
335+// OverridePreferredModel sets the preferred model for the given type in
336+// memory only, without persisting. It is for per-run overrides (such as the
337+// non-interactive --model flags) that must not be written to the user's
338+// config file.
339+func (s *ConfigStore) OverridePreferredModel(modelType SelectedModelType, model SelectedModel) {
340+ s.mutateInMemory(func(c *Config) {
341+ if c.Models == nil {
342+ c.Models = make(map[SelectedModelType]SelectedModel)
343+ }
344+ c.Models[modelType] = model
345+ })
346+}
347+
279348// RemoveConfigField removes a key from the config file for the given scope.
280349// After a successful write, it automatically reloads config to keep in-memory
281350// state fresh.
@@ -304,47 +373,45 @@ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
304373// persists it to the config file at the given scope. The selected model and
305374// the recent-models list are written together in a single config write.
306375//
307−// The in-memory config is updated directly here, so the write skips the
308−// full disk reparse/reload. The reload would otherwise rebuild the entire
309−// provider catalog and agents on every model switch, which dominates the
310−// latency of selecting a model. Agents are refreshed separately by the
311−// caller (see UpdateAgentModel).
376+// The write skips the full disk reparse/reload (which would rebuild the
377+// provider catalog and agents on every model switch and dominate selection
378+// latency); agents are refreshed separately by the caller (see
379+// UpdateAgentModel).
312380func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
313− s.config.Models[modelType] = model
314−
315− fields := map[string]any{
316− fmt.Sprintf("models.%s", modelType): model,
317− }
318−
319− // Compute the updated recent-models list in memory. When it changes,
320− // fold the write into the same batch so we avoid a second write.
321− if updated, changed := s.nextRecentModels(modelType, model); changed {
322− s.config.RecentModels[modelType] = updated
323− fields[fmt.Sprintf("recent_models.%s", modelType)] = updated
324− }
381+ return s.update(scope, func(c *Config) map[string]any {
382+ if c.Models == nil {
383+ c.Models = make(map[SelectedModelType]SelectedModel)
384+ }
385+ c.Models[modelType] = model
325386
326− if err := s.setConfigFields(scope, fields, false); err != nil {
327− return fmt.Errorf("failed to update preferred model: %w", err)
328− }
329− return nil
387+ fields := map[string]any{
388+ fmt.Sprintf("models.%s", modelType): model,
389+ }
390+ if updated, changed := nextRecentModels(c, modelType, model); changed {
391+ if c.RecentModels == nil {
392+ c.RecentModels = make(map[SelectedModelType][]SelectedModel)
393+ }
394+ c.RecentModels[modelType] = updated
395+ fields[fmt.Sprintf("recent_models.%s", modelType)] = updated
396+ }
397+ return fields
398+ })
330399}
331400
332401// SetCompactMode sets the compact mode setting and persists it.
333402func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
334− if s.config.Options == nil {
335− s.config.Options = &Options{}
336− }
337− s.config.Options.TUI.CompactMode = enabled
338− return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
403+ return s.update(scope, func(c *Config) map[string]any {
404+ c.ensureTUI().CompactMode = enabled
405+ return map[string]any{"options.tui.compact_mode": enabled}
406+ })
339407}
340408
341409// SetTransparentBackground sets the transparent background setting and persists it.
342410func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
343− if s.config.Options == nil {
344− s.config.Options = &Options{}
345− }
346− s.config.Options.TUI.Transparent = &enabled
347− return s.SetConfigField(scope, "options.tui.transparent", enabled)
411+ return s.update(scope, func(c *Config) map[string]any {
412+ c.ensureTUI().Transparent = &enabled
413+ return map[string]any{"options.tui.transparent": enabled}
414+ })
348415}
349416
350417// SetProviderAPIKey sets the API key for a provider and persists it.
@@ -376,10 +443,11 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
376443 }
377444 }
378445
379− providerConfig, exists = s.config.Providers.Get(providerID)
446+ cfg := s.Config()
447+ providerConfig, exists = cfg.Providers.Get(providerID)
380448 if exists {
381449 setKeyOrToken()
382− s.config.Providers.Set(providerID, providerConfig)
450+ cfg.Providers.Set(providerID, providerConfig)
383451 return nil
384452 }
385453
@@ -406,7 +474,7 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
406474 } else {
407475 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
408476 }
409− s.config.Providers.Set(providerID, providerConfig)
477+ cfg.Providers.Set(providerID, providerConfig)
410478 return nil
411479}
412480
@@ -420,7 +488,8 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
420488// already rotated the refresh token — the disk is re-checked under lock
421489// to recover the other process's token.
422490func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
423− providerConfig, exists := s.config.Providers.Get(providerID)
491+ cfg := s.Config()
492+ providerConfig, exists := cfg.Providers.Get(providerID)
424493 if !exists {
425494 return fmt.Errorf("provider %s not found", providerID)
426495 }
@@ -482,7 +551,7 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
482551 providerConfig.SetupGitHubCopilot()
483552 }
484553
485− s.config.Providers.Set(providerID, providerConfig)
554+ cfg.Providers.Set(providerID, providerConfig)
486555
487556 if err := s.SetConfigFields(scope, map[string]any{
488557 fmt.Sprintf("providers.%s.api_key", providerID): refreshedToken.AccessToken,
@@ -501,7 +570,7 @@ func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Tok
501570 if providerID == string(catwalk.InferenceProviderCopilot) {
502571 providerConfig.SetupGitHubCopilot()
503572 }
504− s.config.Providers.Set(providerID, providerConfig)
573+ s.Config().Providers.Set(providerID, providerConfig)
505574 return nil
506575}
507576
@@ -541,20 +610,15 @@ func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.
541610}
542611
543612// nextRecentModels computes the recent-models list for the given type
544−// after recording the supplied model at the front, without persisting
545−// anything. It returns the new slice and whether it differs from the
546−// current list. It also lazily initializes the RecentModels map. Callers
547−// that want to persist the change can fold the result into a batched
548−// config write; recordRecentModel wraps it for the standalone case.
549−func (s *ConfigStore) nextRecentModels(modelType SelectedModelType, model SelectedModel) ([]SelectedModel, bool) {
613+// after recording the supplied model at the front, operating on the
614+// provided config without persisting anything. It returns the new slice
615+// and whether it differs from cfg's current list. Callers fold the result
616+// into a clone they are about to publish.
617+func nextRecentModels(cfg *Config, modelType SelectedModelType, model SelectedModel) ([]SelectedModel, bool) {
550618 if model.Provider == "" || model.Model == "" {
551619 return nil, false
552620 }
553621
554− if s.config.RecentModels == nil {
555− s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
556− }
557−
558622 eq := func(a, b SelectedModel) bool {
559623 return a.Provider == b.Provider && a.Model == b.Model
560624 }
@@ -564,7 +628,7 @@ func (s *ConfigStore) nextRecentModels(modelType SelectedModelType, model Select
564628 Model: model.Model,
565629 }
566630
567− current := s.config.RecentModels[modelType]
631+ current := cfg.RecentModels[modelType]
568632 withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
569633 return eq(existing, entry)
570634 })
@@ -581,25 +645,6 @@ func (s *ConfigStore) nextRecentModels(modelType SelectedModelType, model Select
581645 return updated, true
582646}
583647
584−// recordRecentModel records a model in the recent models list and persists
585−// the change. It is retained for callers that update recent models in
586−// isolation; UpdatePreferredModel batches the same change into a single
587−// config write instead.
588−func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
589− updated, changed := s.nextRecentModels(modelType, model)
590− if !changed {
591− return nil
592− }
593−
594− s.config.RecentModels[modelType] = updated
595−
596− if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
597− return fmt.Errorf("failed to persist recent models: %w", err)
598− }
599−
600− return nil
601−}
602−
603648// NewTestStore creates a ConfigStore for testing purposes.
604649func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
605650 return &ConfigStore{
@@ -777,17 +822,17 @@ func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
777822// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
778823// config atomically. It rebuilds the staleness snapshot after successful reload.
779824// On failure, the store state is rolled back to its previous state.
780−// Concurrent calls are serialised via reloadMu.
825+// Concurrent calls are serialised via writeMu.
781826func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
782827 if s.workingDir == "" {
783828 return fmt.Errorf("cannot reload: working directory not set")
784829 }
785− s.reloadMu.Lock()
786− defer s.reloadMu.Unlock()
830+ s.writeMu.Lock()
831+ defer s.writeMu.Unlock()
787832 return s.reloadFromDiskLocked(ctx)
788833}
789834
790−// reloadFromDiskLocked performs the actual reload. Caller must hold reloadMu.
835+// reloadFromDiskLocked performs the actual reload. Caller must hold writeMu.
791836func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
792837 // Migrate deprecated disable_notifications before reloading config.
793838 migrateDisableNotifications()
@@ -800,8 +845,8 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
800845
801846 // Apply defaults (using existing data directory if set)
802847 var dataDir string
803− if s.config != nil && s.config.Options != nil {
804− dataDir = s.config.Options.DataDirectory
848+ if cur := s.Config(); cur != nil && cur.Options != nil {
849+ dataDir = cur.Options.DataDirectory
805850 }
806851 cfg.setDefaults(s.workingDir, dataDir)
807852
@@ -842,7 +887,7 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
842887 }
843888
844889 // Save current state for potential rollback
845− oldConfig := s.config
890+ oldConfig := s.Config()
846891 oldLoadedPaths := s.loadedPaths
847892 oldResolver := s.resolver
848893 oldKnownProviders := s.knownProviders
@@ -850,7 +895,7 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
850895 oldWorkspacePath := s.workspacePath
851896
852897 // Update store state BEFORE running model/agent setup (so they see new config)
853− s.config = cfg
898+ s.setConfig(cfg)
854899 s.loadedPaths = loadedPaths
855900 s.resolver = resolver
856901 s.knownProviders = providers
@@ -871,7 +916,7 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
871916
872917 // Rollback on setup failure
873918 if setupErr != nil {
874− s.config = oldConfig
919+ s.setConfig(oldConfig)
875920 s.loadedPaths = oldLoadedPaths
876921 s.resolver = oldResolver
877922 s.knownProviders = oldKnownProviders
@@ -904,9 +949,9 @@ func (s *ConfigStore) autoReload(ctx context.Context) error {
904949 // are rare and the next user action or file-watch tick will pick
905950 // up the change. Callers that need guaranteed fresh state after a
906951 // write should call ReloadFromDisk explicitly.
907− if !s.reloadMu.TryLock() {
952+ if !s.writeMu.TryLock() {
908953 return nil
909954 }
910− defer s.reloadMu.Unlock()
955+ defer s.writeMu.Unlock()
911956 return s.reloadFromDiskLocked(ctx)
912957}
internal/config/store_test.go+1−1
@@ -498,7 +498,7 @@ func TestAutoReloadDisabledDuringReload(t *testing.T) {
498498 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
499499
500500 // Load will trigger configureProviders which removes anthropic OAuth config.
501− // This should NOT cause infinite recursion — reloadMu prevents re-entrant reloads.
501+ // This should NOT cause infinite recursion — writeMu prevents re-entrant reloads.
502502 store, err := Load(dir, dir, false)
503503 require.NoError(t, err)
504504
internal/workspace/app_workspace.go+2−2
@@ -426,13 +426,13 @@ func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
426426
427427 if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
428428 disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
429− delete(w.store.Config().MCP, config.DockerMCPName)
429+ w.store.RemoveDockerMCPInMemory()
430430 return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
431431 }
432432
433433 if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
434434 disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
435− delete(w.store.Config().MCP, config.DockerMCPName)
435+ w.store.RemoveDockerMCPInMemory()
436436 return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
437437 }
438438