@@ -10,6 +10,7 @@ import (
11 "path/filepath"
11 "path/filepath"
14 "charm.land/catwalk/pkg/catwalk"
15 "charm.land/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
@@ -1687,14 +1688,17 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1687 err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
1688 err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
1688 require.NoError(t, err)
1689 require.NoError(t, err)
1690− err = configureSelectedModels(store, knownProviders, false)
1691+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1691− require.NoError(t, err)
1692+ require.NoError(t, resolveErr)
1693+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1694+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1693 // In-memory falls back to default.
1696 // In-memory falls back to default.
1697+ require.True(t, resolved.LargeFallback)
1694 require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1698 require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1695 require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1699 require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1697− // Disk remains unchanged in reload mode.
1701+ // Disk remains unchanged (resolveSelectedModels never persists).
1698 data, readErr := os.ReadFile(globalPath)
1702 data, readErr := os.ReadFile(globalPath)
1699 require.NoError(t, readErr)
1703 require.NoError(t, readErr)
1700 require.Contains(t, string(data), `"provider":"ghost"`)
1704 require.Contains(t, string(data), `"provider":"ghost"`)
@@ -1737,8 +1741,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1737 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1741 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1738 require.NoError(t, err)
1742 require.NoError(t, err)
1740− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1744+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1741− require.NoError(t, err)
1745+ require.NoError(t, resolveErr)
1746+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1747+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1742 large := cfg.Models[SelectedModelTypeLarge]
1748 large := cfg.Models[SelectedModelTypeLarge]
1743 small := cfg.Models[SelectedModelTypeSmall]
1749 small := cfg.Models[SelectedModelTypeSmall]
1744 require.Equal(t, "larger-model", large.Model)
1750 require.Equal(t, "larger-model", large.Model)
@@ -1799,8 +1805,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1799 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1805 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1800 require.NoError(t, err)
1806 require.NoError(t, err)
1802− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1808+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1803− require.NoError(t, err)
1809+ require.NoError(t, resolveErr)
1810+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1811+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1804 large := cfg.Models[SelectedModelTypeLarge]
1812 large := cfg.Models[SelectedModelTypeLarge]
1805 small := cfg.Models[SelectedModelTypeSmall]
1813 small := cfg.Models[SelectedModelTypeSmall]
1806 require.Equal(t, "large-model", large.Model)
1814 require.Equal(t, "large-model", large.Model)
@@ -1844,13 +1852,91 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1844 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1852 err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1845 require.NoError(t, err)
1853 require.NoError(t, err)
1847− err = configureSelectedModels(testStore(cfg), knownProviders, true)
1855+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1848− require.NoError(t, err)
1856+ require.NoError(t, resolveErr)
1857+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1858+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1849 large := cfg.Models[SelectedModelTypeLarge]
1859 large := cfg.Models[SelectedModelTypeLarge]
1850 require.Equal(t, "large-model", large.Model)
1860 require.Equal(t, "large-model", large.Model)
1851 require.Equal(t, "openai", large.Provider)
1861 require.Equal(t, "openai", large.Provider)
1852 require.Equal(t, int64(100), large.MaxTokens)
1862 require.Equal(t, int64(100), large.MaxTokens)
1864+ t.Run("resolve and persist fallback under writeMu does not deadlock", func(t *testing.T) {
1866+ globalPath := filepath.Join(dir, "crush.json")
1867+ require.NoError(t, os.WriteFile(globalPath, []byte(`{}`), 0o600))
1869+ knownProviders := []catwalk.Provider{
1873+ DefaultLargeModelID: "large-model",
1874+ DefaultSmallModelID: "small-model",
1875+ Models: []catwalk.Model{
1876+ {ID: "large-model", DefaultMaxTokens: 1000},
1877+ {ID: "small-model", DefaultMaxTokens: 500},
1883+ Models: map[SelectedModelType]SelectedModel{
1884+ SelectedModelTypeLarge: {Provider: "openai", Model: "this-model-does-not-exist"},
1885+ SelectedModelTypeSmall: {Provider: "openai", Model: "also-does-not-exist"},
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)
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)
1901+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1902+ if resolveErr != nil {
1906+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1907+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
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)
1919+ if resolved.SmallFallback {
1920+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
1921+ return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
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")
1856func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
1942func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {