@@ -10,6 +10,7 @@ import (
1010 "os/exec"
1111 "path/filepath"
1212 "testing"
13+ "time"
1314
1415 "charm.land/catwalk/pkg/catwalk"
1516 "github.com/charmbracelet/crush/internal/csync"
@@ -1687,14 +1688,17 @@ func TestConfig_configureSelectedModels(t *testing.T) {
16871688 err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
16881689 require.NoError(t, err)
16891690
1690− err = configureSelectedModels(store, knownProviders, false)
1691− require.NoError(t, err)
1691+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1692+ require.NoError(t, resolveErr)
1693+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1694+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
16921695
16931696 // In-memory falls back to default.
1697+ require.True(t, resolved.LargeFallback)
16941698 require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
16951699 require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
16961700
1697− // Disk remains unchanged in reload mode.
1701+ // Disk remains unchanged (resolveSelectedModels never persists).
16981702 data, readErr := os.ReadFile(globalPath)
16991703 require.NoError(t, readErr)
17001704 require.Contains(t, string(data), `"provider":"ghost"`)
@@ -1737,8 +1741,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
17371741 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
17381742 require.NoError(t, err)
17391743
1740− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1741− require.NoError(t, err)
1744+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1745+ require.NoError(t, resolveErr)
1746+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1747+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
17421748 large := cfg.Models[SelectedModelTypeLarge]
17431749 small := cfg.Models[SelectedModelTypeSmall]
17441750 require.Equal(t, "larger-model", large.Model)
@@ -1799,8 +1805,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
17991805 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
18001806 require.NoError(t, err)
18011807
1802− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1803− require.NoError(t, err)
1808+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1809+ require.NoError(t, resolveErr)
1810+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1811+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
18041812 large := cfg.Models[SelectedModelTypeLarge]
18051813 small := cfg.Models[SelectedModelTypeSmall]
18061814 require.Equal(t, "large-model", large.Model)
@@ -1844,13 +1852,91 @@ func TestConfig_configureSelectedModels(t *testing.T) {
18441852 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
18451853 require.NoError(t, err)
18461854
1847− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1848− require.NoError(t, err)
1855+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1856+ require.NoError(t, resolveErr)
1857+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1858+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
18491859 large := cfg.Models[SelectedModelTypeLarge]
18501860 require.Equal(t, "large-model", large.Model)
18511861 require.Equal(t, "openai", large.Provider)
18521862 require.Equal(t, int64(100), large.MaxTokens)
18531863 })
1864+ t.Run("resolve and persist fallback under writeMu does not deadlock", func(t *testing.T) {
1865+ dir := t.TempDir()
1866+ globalPath := filepath.Join(dir, "crush.json")
1867+ require.NoError(t, os.WriteFile(globalPath, []byte(`{}`), 0o600))
1868+
1869+ knownProviders := []catwalk.Provider{
1870+ {
1871+ ID: "openai",
1872+ APIKey: "abc",
1873+ DefaultLargeModelID: "large-model",
1874+ DefaultSmallModelID: "small-model",
1875+ Models: []catwalk.Model{
1876+ {ID: "large-model", DefaultMaxTokens: 1000},
1877+ {ID: "small-model", DefaultMaxTokens: 500},
1878+ },
1879+ },
1880+ }
1881+
1882+ cfg := &Config{
1883+ Models: map[SelectedModelType]SelectedModel{
1884+ SelectedModelTypeLarge: {Provider: "openai", Model: "this-model-does-not-exist"},
1885+ SelectedModelTypeSmall: {Provider: "openai", Model: "also-does-not-exist"},
1886+ },
1887+ }
1888+ cfg.setDefaults(dir, "")
1889+ store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1890+ env := env.NewFromMap(map[string]string{})
1891+ resolver := NewShellVariableResolver(env)
1892+ err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
1893+ require.NoError(t, err)
1894+
1895+ // Simulate the Load path: resolve (pure), then persist fallbacks
1896+ // under writeMu using updateLocked. Before the refactor, the
1897+ // combined configureSelectedModels(persist=true) self-deadlocked
1898+ // because UpdatePreferredModel re-acquired writeMu.
1899+ done := make(chan error, 1)
1900+ go func() {
1901+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1902+ if resolveErr != nil {
1903+ done <- resolveErr
1904+ return
1905+ }
1906+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1907+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1908+
1909+ store.writeMu.Lock()
1910+ defer store.writeMu.Unlock()
1911+ if resolved.LargeFallback {
1912+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
1913+ return store.updatePreferredModelFields(c, SelectedModelTypeLarge, resolved.Large)
1914+ }); err != nil {
1915+ done <- err
1916+ return
1917+ }
1918+ }
1919+ if resolved.SmallFallback {
1920+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
1921+ return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
1922+ }); err != nil {
1923+ done <- err
1924+ return
1925+ }
1926+ }
1927+ done <- nil
1928+ }()
1929+
1930+ select {
1931+ case err := <-done:
1932+ require.NoError(t, err)
1933+ // Should have fallen back to defaults.
1934+ require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1935+ require.Equal(t, "small-model", cfg.Models[SelectedModelTypeSmall].Model)
1936+ case <-time.After(5 * time.Second):
1937+ t.Fatal("resolve + persist deadlocked under writeMu")
1938+ }
1939+ })
18541940}
18551941
18561942func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {