From c21ce617298f11e0ac4733a43d26917197612518 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:28:29 +0200 Subject: [PATCH] Synchronize ViewBufferManager.Close with a starting task Close read and called stopCurrentTask with no lock, while NewTask's goroutine assigns it (and constructs the sync.Once it closes over) under waitingMutex. On shutdown Close runs while a render task spawned by the last layout is still starting, so the two raced on the field and the once (three DATA RACE blocks under -race, e.g. cherry_pick). Read stopCurrentTask once under waitingMutex and call the captured value instead of re-reading the field, which establishes the happens-before the once needs. This can't deadlock: no task holds waitingMutex across a blocking UI-thread hop, so Close can always take it, and a task wedged in such a hop is still bounded by the existing 3s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index bc08013ed..9da12d40b 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -393,14 +393,21 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // Close closes the task manager, killing whatever task may currently be running func (self *ViewBufferManager) Close() { - if self.stopCurrentTask == nil { + // stopCurrentTask is written by NewTask's goroutine under waitingMutex (and + // so is the sync.Once it closes over), so read it under the lock and call + // the captured value; a task starting on shutdown must not race us here. + self.waitingMutex.Lock() + stopCurrentTask := self.stopCurrentTask + self.waitingMutex.Unlock() + + if stopCurrentTask == nil { return } c := make(chan struct{}) go utils.Safe(func() { - self.stopCurrentTask() + stopCurrentTask() c <- struct{}{} })