on main help sign in
fix(app): cancel and join background goroutines on shutdown

app.New fired checkForUpdates, mcp.Initialize, and LSPManager.TrackConfigured as fire-and-forget goroutines on a context that is context.Background() in the backend/server path, and Shutdown never joined them — so they outlived teardown and could touch a closing config store, DB, or MCP registry (the leaked mcp.Initialize goroutine reading store.resolver was the reader in the config-store data race). Give the app a cancelable background context (bgCancel) and a WaitGroup (bgWG); launch those goroutines through goBackground so Shutdown can cancel them and join with a bound (the existing 5s shutdownCtx) before the DB/MCP cleanup runs. Bounded so a goroutine that ignores cancellation can't hang shutdown.

commit6c2ba6264dd837b639d07458ad0bb8996c4c549e authoriko <mail@iko.soy> dateWed 8 Jul 2026, 20:24 (3 months ago) parentab74a3b
1 file changed+45−5
internal/app/app.go+45−5
internal/app/app.go+45−5
@@ -74,8 +74,13 @@ type App struct {
7474 tuiWG *sync.WaitGroup
7575
7676 // global context and cleanup functions
77− globalCtx context.Context
78− cleanupFuncs []func(context.Context) error
77+ globalCtx context.Context
78+ cleanupFuncs []func(context.Context) error
79+ // bgCancel cancels the context passed to the fire-and-forget
80+ // goroutines launched from New (update check, MCP init, LSP tracking);
81+ // bgWG tracks them so Shutdown can join them before closing the DB.
82+ bgCancel context.CancelFunc
83+ bgWG sync.WaitGroup
7984 agentNotifications *pubsub.Broker[notify.Notification]
8085 // runCompletions is the authoritative per-run completion signal,
8186 // emitted once per top-level agent turn after all message
@@ -134,10 +139,16 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
134139 slog.Warn("Clipboard initialization failed", "error", err)
135140 }
136141
142+ // Background goroutines run on a cancelable context so Shutdown can
143+ // stop and join them (see goBackground / Shutdown) rather than leaking
144+ // them past teardown.
145+ bgCtx, bgCancel := context.WithCancel(ctx)
146+ app.bgCancel = bgCancel
147+
137148 // Check for updates in the background.
138− go app.checkForUpdates(ctx)
149+ app.goBackground(func() { app.checkForUpdates(bgCtx) })
139150
140− go mcp.Initialize(ctx, app.Permissions, store)
151+ app.goBackground(func() { mcp.Initialize(bgCtx, app.Permissions, store) })
141152
142153 // Start herdr integration when running inside a herdr pane.
143154 app.herdrClient = herdr.Init()
@@ -175,11 +186,22 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
175186 client.SetDiagnosticsCallback(updateLSPDiagnostics)
176187 updateLSPState(name, client.GetServerState(), nil, client, 0)
177188 })
178− go app.LSPManager.TrackConfigured()
189+ app.goBackground(app.LSPManager.TrackConfigured)
179190
180191 return app, nil
181192}
182193
194+// goBackground runs fn in a goroutine tracked by bgWG so Shutdown can join
195+// it. Callers whose work is cancelable should close over the app's
196+// background context (see New) so bgCancel actually interrupts them.
197+func (app *App) goBackground(fn func()) {
198+ app.bgWG.Add(1)
199+ go func() {
200+ defer app.bgWG.Done()
201+ fn()
202+ }()
203+}
204+
183205// Config returns the pure-data configuration.
184206func (app *App) Config() *config.Config {
185207 return app.config.Config()
@@ -692,6 +714,24 @@ func (app *App) Shutdown() {
692714 }
693715 }
694716
717+ // Cancel and join the fire-and-forget goroutines from New before the
718+ // DB/MCP cleanup below, so none keep touching a closing store, DB, or
719+ // MCP registry. Bounded by shutdownCtx so a goroutine that ignores
720+ // cancellation can't hang shutdown.
721+ if app.bgCancel != nil {
722+ app.bgCancel()
723+ }
724+ bgJoined := make(chan struct{})
725+ go func() {
726+ app.bgWG.Wait()
727+ close(bgJoined)
728+ }()
729+ select {
730+ case <-bgJoined:
731+ case <-shutdownCtx.Done():
732+ slog.Warn("Timed out waiting for background goroutines on shutdown")
733+ }
734+
695735 // Now run remaining cleanup tasks in parallel.
696736 var wg sync.WaitGroup
697737