on main help sign in
fix: improving shutdown (#2175)
63e0098Carlos Alexandro Becker
go.mod+1−1
@@ -31,7 +31,7 @@ require (
31 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/ordered v0.1.0
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
33 github.com/charmbracelet/x/exp/strings v0.1.0
33 github.com/charmbracelet/x/exp/strings v0.1.0
34− github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
34+ github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c
35 github.com/charmbracelet/x/term v0.2.2
35 github.com/charmbracelet/x/term v0.2.2
36 github.com/clipperhouse/displaywidth v0.10.0
36 github.com/clipperhouse/displaywidth v0.10.0
37 github.com/clipperhouse/uax29/v2 v2.6.0
37 github.com/clipperhouse/uax29/v2 v2.6.0
go.sum+2−2
@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
122github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
122github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
125−github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
125+github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c h1:6E+Y7WQ6Rnw+FmeXoRBtyCBkPcXS0hSMuws6QBr+nyQ=
126−github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
126+github.com/charmbracelet/x/powernap v0.0.0-20260209132835-6b065b8ba62c/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/agent/tools/mcp/init.go+1−4
@@ -123,10 +123,7 @@ func GetState(name string) (ClientInfo, bool) {
123}
123}
124
124
125// Close closes all MCP clients. This should be called during application shutdown.
125// Close closes all MCP clients. This should be called during application shutdown.
126−func Close() error {
126+func Close(ctx context.Context) error {
127− ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
128− defer cancel()
129−
130 var wg sync.WaitGroup
127 var wg sync.WaitGroup
131 for name, session := range sessions.Seq2() {
128 for name, session := range sessions.Seq2() {
132 wg.Go(func() {
129 wg.Go(func() {
internal/app/app.go+16−6
@@ -22,6 +22,7 @@ import (
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/db"
24 "github.com/charmbracelet/crush/internal/db"
25+ "github.com/charmbracelet/crush/internal/event"
25 "github.com/charmbracelet/crush/internal/filetracker"
26 "github.com/charmbracelet/crush/internal/filetracker"
26 "github.com/charmbracelet/crush/internal/format"
27 "github.com/charmbracelet/crush/internal/format"
27 "github.com/charmbracelet/crush/internal/history"
28 "github.com/charmbracelet/crush/internal/history"
@@ -68,7 +69,7 @@ type App struct {
68
69
69 // global context and cleanup functions
70 // global context and cleanup functions
70 globalCtx context.Context
71 globalCtx context.Context
71− cleanupFuncs []func() error
72+ cleanupFuncs []func(context.Context) error
72}
73}
73
74
74// New initializes a new application instance.
75// New initializes a new application instance.
@@ -108,7 +109,11 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
108 go mcp.Initialize(ctx, app.Permissions, cfg)
109 go mcp.Initialize(ctx, app.Permissions, cfg)
109
110
110 // cleanup database upon app shutdown
111 // cleanup database upon app shutdown
111− app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
112+ app.cleanupFuncs = append(
113+ app.cleanupFuncs,
114+ func(context.Context) error { return conn.Close() },
115+ mcp.Close,
116+ )
112
117
113 // TODO: remove the concept of agent config, most likely.
118 // TODO: remove the concept of agent config, most likely.
114 if !cfg.IsConfigured() {
119 if !cfg.IsConfigured() {
@@ -416,7 +421,7 @@ func (app *App) setupEvents() {
416 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
421 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
417 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
422 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
418 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
423 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
419− cleanupFunc := func() error {
424+ cleanupFunc := func(context.Context) error {
420 cancel()
425 cancel()
421 app.serviceEventsWG.Wait()
426 app.serviceEventsWG.Wait()
422 return nil
427 return nil
@@ -503,7 +508,7 @@ func (app *App) Subscribe(program *tea.Program) {
503
508
504 app.tuiWG.Add(1)
509 app.tuiWG.Add(1)
505 tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
510 tuiCtx, tuiCancel := context.WithCancel(app.globalCtx)
506− app.cleanupFuncs = append(app.cleanupFuncs, func() error {
511+ app.cleanupFuncs = append(app.cleanupFuncs, func(context.Context) error {
507 slog.Debug("Cancelling TUI message handler")
512 slog.Debug("Cancelling TUI message handler")
508 tuiCancel()
513 tuiCancel()
509 app.tuiWG.Wait()
514 app.tuiWG.Wait()
@@ -544,6 +549,11 @@ func (app *App) Shutdown() {
544 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
549 shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second)
545 defer cancel()
550 defer cancel()
546
551
552+ // Send exit event
553+ wg.Go(func() {
554+ event.AppExited()
555+ })
556+
547 // Kill all background shells.
557 // Kill all background shells.
548 wg.Go(func() {
558 wg.Go(func() {
549 shell.GetBackgroundShellManager().KillAll(shutdownCtx)
559 shell.GetBackgroundShellManager().KillAll(shutdownCtx)
@@ -551,14 +561,14 @@ func (app *App) Shutdown() {
551
561
552 // Shutdown all LSP clients.
562 // Shutdown all LSP clients.
553 wg.Go(func() {
563 wg.Go(func() {
554− app.LSPManager.StopAll(shutdownCtx)
564+ app.LSPManager.KillAll(shutdownCtx)
555 })
565 })
556
566
557 // Call all cleanup functions.
567 // Call all cleanup functions.
558 for _, cleanup := range app.cleanupFuncs {
568 for _, cleanup := range app.cleanupFuncs {
559 if cleanup != nil {
569 if cleanup != nil {
560 wg.Go(func() {
570 wg.Go(func() {
561− if err := cleanup(); err != nil {
571+ if err := cleanup(shutdownCtx); err != nil {
562 slog.Error("Failed to cleanup app properly on shutdown", "error", err)
572 slog.Error("Failed to cleanup app properly on shutdown", "error", err)
563 }
573 }
564 })
574 })
internal/cmd/dirs_test.go+2
@@ -12,6 +12,8 @@ import (
12func init() {
12func init() {
13 os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
13 os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
14 os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
14 os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
15+ os.Unsetenv("CRUSH_GLOBAL_CONFIG")
16+ os.Unsetenv("CRUSH_GLOBAL_DATA")
15}
17}
16
18
17func TestDirs(t *testing.T) {
19func TestDirs(t *testing.T) {
internal/cmd/root.go−3
@@ -106,9 +106,6 @@ crush -y
106 }
106 }
107 return nil
107 return nil
108 },
108 },
109− PostRun: func(cmd *cobra.Command, args []string) {
110− event.AppExited()
111− },
112}
109}
113
110
114var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
111var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
internal/lsp/client.go+4−1
@@ -117,7 +117,10 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol
117 return result, nil
117 return result, nil
118}
118}
119
119
120+// Kill kills the client without doing anything else.
121+func (c *Client) Kill() { c.client.Kill() }
122+
120−// Close closes the LSP client.
123+// Close closes all open files in the client, then the client.
121func (c *Client) Close(ctx context.Context) error {
124func (c *Client) Close(ctx context.Context) error {
122 c.CloseAllFiles(ctx)
125 c.CloseAllFiles(ctx)
123
126
internal/lsp/manager.go+22
@@ -255,6 +255,28 @@ func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool
255 hasRootMarkers(workDir, server.RootMarkers)
255 hasRootMarkers(workDir, server.RootMarkers)
256}
256}
257
257
258+// KillAll force-kills all the LSP clients.
259+//
260+// This is generally faster than [Manager.StopAll] because it doesn't wait for
261+// the server to exit gracefully, but it can lead to data loss if the server is
262+// in the middle of writing something.
263+// Generally it doesn't matter when shutting down Crush, though.
264+func (s *Manager) KillAll(context.Context) {
265+ s.mu.Lock()
266+ defer s.mu.Unlock()
267+
268+ var wg sync.WaitGroup
269+ for name, client := range s.clients.Seq2() {
270+ wg.Go(func() {
271+ defer func() { s.callback(name, client) }()
272+ client.client.Kill()
273+ client.SetServerState(StateStopped)
274+ slog.Debug("Killed LSP client", "name", name)
275+ })
276+ }
277+ wg.Wait()
278+}
279+
258// StopAll stops all running LSP clients and clears the client map.
280// StopAll stops all running LSP clients and clears the client map.
259func (s *Manager) StopAll(ctx context.Context) {
281func (s *Manager) StopAll(ctx context.Context) {
260 s.mu.Lock()
282 s.mu.Lock()
internal/projects/projects_test.go+6
@@ -12,6 +12,7 @@ func TestRegisterAndList(t *testing.T) {
12
12
13 // Override the projects file path for testing
13 // Override the projects file path for testing
14 t.Setenv("XDG_DATA_HOME", tmpDir)
14 t.Setenv("XDG_DATA_HOME", tmpDir)
15+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
15
16
16 // Test registering a project
17 // Test registering a project
17 err := Register("/home/user/project1", "/home/user/project1/.crush")
18 err := Register("/home/user/project1", "/home/user/project1/.crush")
@@ -61,6 +62,7 @@ func TestRegisterAndList(t *testing.T) {
61func TestRegisterUpdatesExisting(t *testing.T) {
62func TestRegisterUpdatesExisting(t *testing.T) {
62 tmpDir := t.TempDir()
63 tmpDir := t.TempDir()
63 t.Setenv("XDG_DATA_HOME", tmpDir)
64 t.Setenv("XDG_DATA_HOME", tmpDir)
65+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
64
66
65 // Register a project
67 // Register a project
66 err := Register("/home/user/project1", "/home/user/project1/.crush")
68 err := Register("/home/user/project1", "/home/user/project1/.crush")
@@ -97,6 +99,7 @@ func TestRegisterUpdatesExisting(t *testing.T) {
97func TestLoadEmptyFile(t *testing.T) {
99func TestLoadEmptyFile(t *testing.T) {
98 tmpDir := t.TempDir()
100 tmpDir := t.TempDir()
99 t.Setenv("XDG_DATA_HOME", tmpDir)
101 t.Setenv("XDG_DATA_HOME", tmpDir)
102+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
100
103
101 // List before any projects exist
104 // List before any projects exist
102 projects, err := List()
105 projects, err := List()
@@ -112,6 +115,7 @@ func TestLoadEmptyFile(t *testing.T) {
112func TestProjectsFilePath(t *testing.T) {
115func TestProjectsFilePath(t *testing.T) {
113 tmpDir := t.TempDir()
116 tmpDir := t.TempDir()
114 t.Setenv("XDG_DATA_HOME", tmpDir)
117 t.Setenv("XDG_DATA_HOME", tmpDir)
118+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
115
119
116 expected := filepath.Join(tmpDir, "crush", "projects.json")
120 expected := filepath.Join(tmpDir, "crush", "projects.json")
117 actual := projectsFilePath()
121 actual := projectsFilePath()
@@ -124,6 +128,7 @@ func TestProjectsFilePath(t *testing.T) {
124func TestRegisterWithParentDataDir(t *testing.T) {
128func TestRegisterWithParentDataDir(t *testing.T) {
125 tmpDir := t.TempDir()
129 tmpDir := t.TempDir()
126 t.Setenv("XDG_DATA_HOME", tmpDir)
130 t.Setenv("XDG_DATA_HOME", tmpDir)
131+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
127
132
128 // Register a project where .crush is in a parent directory.
133 // Register a project where .crush is in a parent directory.
129 // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
134 // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
@@ -153,6 +158,7 @@ func TestRegisterWithParentDataDir(t *testing.T) {
153func TestRegisterWithExternalDataDir(t *testing.T) {
158func TestRegisterWithExternalDataDir(t *testing.T) {
154 tmpDir := t.TempDir()
159 tmpDir := t.TempDir()
155 t.Setenv("XDG_DATA_HOME", tmpDir)
160 t.Setenv("XDG_DATA_HOME", tmpDir)
161+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(tmpDir, "crush"))
156
162
157 // Register a project where .crush is in a completely different location.
163 // Register a project where .crush is in a completely different location.
158 // e.g., project at /home/user/project but data stored at /var/data/crush/myproject
164 // e.g., project at /home/user/project but data stored at /var/data/crush/myproject
internal/shell/background_test.go+14−11
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
14 t.Skip("Skipping this until I figure out why its flaky")
14 t.Skip("Skipping this until I figure out why its flaky")
15 t.Parallel()
15 t.Parallel()
16
16
17− ctx := context.Background()
17+ ctx := t.Context()
18 workingDir := t.TempDir()
18 workingDir := t.TempDir()
19 manager := newBackgroundShellManager()
19 manager := newBackgroundShellManager()
20
20
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
51func TestBackgroundShellManager_Get(t *testing.T) {
51func TestBackgroundShellManager_Get(t *testing.T) {
52 t.Parallel()
52 t.Parallel()
53
53
54− ctx := context.Background()
54+ ctx := t.Context()
55 workingDir := t.TempDir()
55 workingDir := t.TempDir()
56 manager := newBackgroundShellManager()
56 manager := newBackgroundShellManager()
57
57
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
77func TestBackgroundShellManager_Kill(t *testing.T) {
77func TestBackgroundShellManager_Kill(t *testing.T) {
78 t.Parallel()
78 t.Parallel()
79
79
80− ctx := context.Background()
80+ ctx := t.Context()
81 workingDir := t.TempDir()
81 workingDir := t.TempDir()
82 manager := newBackgroundShellManager()
82 manager := newBackgroundShellManager()
83
83
@@ -119,7 +119,7 @@ func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
119func TestBackgroundShell_IsDone(t *testing.T) {
119func TestBackgroundShell_IsDone(t *testing.T) {
120 t.Parallel()
120 t.Parallel()
121
121
122− ctx := context.Background()
122+ ctx := t.Context()
123 workingDir := t.TempDir()
123 workingDir := t.TempDir()
124 manager := newBackgroundShellManager()
124 manager := newBackgroundShellManager()
125
125
@@ -142,7 +142,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
142func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
142func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
143 t.Parallel()
143 t.Parallel()
144
144
145− ctx := context.Background()
145+ ctx := t.Context()
146 workingDir := t.TempDir()
146 workingDir := t.TempDir()
147 manager := newBackgroundShellManager()
147 manager := newBackgroundShellManager()
148
148
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
180
180
181 t.Parallel()
181 t.Parallel()
182
182
183− ctx := context.Background()
183+ ctx := t.Context()
184 workingDir := t.TempDir()
184 workingDir := t.TempDir()
185 manager := newBackgroundShellManager()
185 manager := newBackgroundShellManager()
186
186
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
224func TestBackgroundShellManager_KillAll(t *testing.T) {
224func TestBackgroundShellManager_KillAll(t *testing.T) {
225 t.Parallel()
225 t.Parallel()
226
226
227− ctx := context.Background()
227+ ctx := t.Context()
228 workingDir := t.TempDir()
228 workingDir := t.TempDir()
229 manager := newBackgroundShellManager()
229 manager := newBackgroundShellManager()
230
230
@@ -250,7 +250,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
250 }
250 }
251
251
252 // Kill all shells
252 // Kill all shells
253− manager.KillAll(context.Background())
253+ manager.KillAll(t.Context())
254
254
255 // Verify all shells are done
255 // Verify all shells are done
256 if !shell1.IsDone() {
256 if !shell1.IsDone() {
@@ -286,19 +286,22 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
286func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) {
286func TestBackgroundShellManager_KillAll_Timeout(t *testing.T) {
287 t.Parallel()
287 t.Parallel()
288
288
289+ // XXX: can't use synctest here - causes --race to trip.
290+
289 workingDir := t.TempDir()
291 workingDir := t.TempDir()
290 manager := newBackgroundShellManager()
292 manager := newBackgroundShellManager()
291
293
292 // Start a shell that traps signals and ignores cancellation.
294 // Start a shell that traps signals and ignores cancellation.
293− _, err := manager.Start(context.Background(), workingDir, nil, "trap '' TERM INT; sleep 60", "")
295+ _, err := manager.Start(t.Context(), workingDir, nil, "trap '' TERM INT; sleep 60", "")
294 require.NoError(t, err)
296 require.NoError(t, err)
295
297
296 // Short timeout to test the timeout path.
298 // Short timeout to test the timeout path.
297− ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
299+ ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
298− defer cancel()
300+ t.Cleanup(cancel)
299
301
300 start := time.Now()
302 start := time.Now()
301 manager.KillAll(ctx)
303 manager.KillAll(ctx)
304+
302 elapsed := time.Since(start)
305 elapsed := time.Since(start)
303
306
304 // Must return promptly after timeout, not hang for 60 seconds.
307 // Must return promptly after timeout, not hang for 60 seconds.