From 303372d91789b0dd0614033b58122fa450ffa2bb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 07:53:14 +0200 Subject: [PATCH 01/22] Perform string-task view updates on the UI thread The closures that render static content to a main view (newStringTask and friends) ran on the ViewBufferManager's task goroutine, calling SetViewContent/SetOrigin/ResetViewOrigin directly on the view. Those touch view state (the line buffer, hover cells, the origin) that the UI thread concurrently reads and mutates while laying out and drawing, so they raced it -- e.g. a string task's SetContent clearing the view's lines while the UI thread's CopyContent read them, or its SetOrigin racing the layout's OriginY read. Bounce the whole closure onto the UI thread instead, so the view is only touched there. The bounce blocks (OnUIThreadAndWaitBackground) so the task still completes only once the content has actually been rendered, which the integration-test idle detection relies on; the background variant keeps it from counting towards the app being busy, matching the existing treatment of view rendering as work that must not block a repo switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index acad4fb75..808a4341d 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -59,8 +59,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -74,9 +76,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - view.SetOrigin(originX, originY) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + view.SetOrigin(originX, originY) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -90,9 +94,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.ResetViewOrigin(view) - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.ResetViewOrigin(view) + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, key); err != nil { From 99c1bcbf23ee9f08e8a694d53af08bc1b59394b7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:04:08 +0200 Subject: [PATCH 02/22] Reset the view origin for a new task on the UI thread When a task renders different content to a view (a new task key), the view's scroll origin is reset to the top via onNewKey. That ran on the task's own goroutine, racing the UI thread, which reads the origin (OriginY) while laying out and drawing the view -- the single largest source of view-render data races. Give ViewBufferManager a bounce primitive (onUIThread) that runs a function on the UI thread and waits for it, and reset the origin through it. This is the first use of the primitive; subsequent commits route the rest of the task's view mutations through it too, so that the view is only ever touched on the UI thread. It runs as background work (OnUIThreadAndWaitBackground) so rendering doesn't count towards the app being busy, matching how the render's gocui task is already created. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 3 +++ pkg/tasks/tasks.go | 30 ++++++++++++++++++++++++------ pkg/tasks/tasks_test.go | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 808a4341d..9f9488048 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -156,6 +156,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { // otherwise make the switch that handler triggers refuse itself. return gui.c.GocuiGui().NewBackgroundTask() }, + // Rendering is background work too (see above), so the view mutations + // it bounces onto the UI thread mustn't count towards being busy. + gui.g.OnUIThreadAndWaitBackground, ) gui.viewBufferManagerMap[view.Name()] = manager } diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 9da12d40b..c8f5b8244 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -74,6 +74,12 @@ type ViewBufferManager struct { // whereas the tasks in this file are about rendering content to a view. newGocuiTask func() gocui.Task + // Runs f on the UI thread and blocks until it has completed. All mutations + // of the view happen through this, so that the view is only ever touched on + // the UI thread (where it is also laid out and drawn), never on the task's + // own goroutine. + onUIThread func(f func() error) error + // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we @@ -110,6 +116,7 @@ func NewViewBufferManager( onEndOfInput func(), onNewKey func(), newGocuiTask func() gocui.Task, + onUIThread func(f func() error) error, ) *ViewBufferManager { return &ViewBufferManager{ Log: log, @@ -120,6 +127,7 @@ func NewViewBufferManager( readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, + onUIThread: onUIThread, } } @@ -460,21 +468,31 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.taskIDMutex.Lock() // Bail out before touching shared view state if a newer task has - // already been queued: if we ran onNewKey here we'd reset the view - // for a task that's about to exit, potentially wiping output the - // winning task has already written. + // already been queued: if we reset the view here we'd do it for a task + // that's about to exit, potentially wiping output the winning task has + // already written. if taskID < self.newTaskID { self.taskIDMutex.Unlock() return } - if self.GetTaskKey() != key && self.onNewKey != nil { - self.onNewKey() - } + resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil self.taskKey = key self.taskIDMutex.Unlock() + if resetOrigin { + // onNewKey resets the view's scroll origin, which is view state the + // UI thread reads while laying out and drawing, so do it there. This + // must happen after releasing taskIDMutex: it blocks until the UI + // thread runs it, and a NewTask call on the UI thread takes + // taskIDMutex, so holding it here would deadlock. + _ = self.onUIThread(func() error { + self.onNewKey() + return nil + }) + } + self.waitingMutex.Lock() // Re-check staleness after acquiring waitingMutex: a newer task diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index c025e8e16..2cea139e8 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -39,6 +39,8 @@ func TestNewCmdTaskInstantStop(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -104,6 +106,8 @@ func TestNewCmdTask(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -237,6 +241,8 @@ func TestNewCmdTaskRefresh(t *testing.T) { func() {}, func() {}, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) From 40868a93895d8920cb89dc084644e9344d403d4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:08:47 +0200 Subject: [PATCH 03/22] Hold the ViewBufferManager readLines channel in an atomic The readLines channel, by which a running task is told to read more lines as the user scrolls, is swapped out as tasks start and finish. It was a plain field written from the task goroutines (when a task starts, ends, or is replaced) and read from the UI thread in ReadLines/ ReadToEnd, so those accesses raced -- a longstanding data race (and a plausible cause of the occasional "main view stops updating" hang, since a torn read there could drop a scroll's read request). Make the field an atomic.Pointer and give the running task a captured local copy of the channel for its own send/receive, so the field itself is only ever loaded/stored atomically. No lock is involved, so there's nothing to untangle later. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c8f5b8244..50a94a15c 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -59,9 +60,13 @@ type ViewBufferManager struct { taskIDMutex deadlock.Mutex Log *logrus.Entry newTaskID int - readLines chan LinesToRead - taskKey string - onNewKey func() + // The channel by which the currently-running task is told to read more + // lines (e.g. as the user scrolls). Held in an atomic because it's swapped + // out as tasks come and go while ReadLines/ReadToEnd read it from the UI + // thread; nil when no task is running. + readLines atomic.Pointer[chan LinesToRead] + taskKey string + onNewKey func() // beforeStart is the function that is called before starting a new task beforeStart func() @@ -124,7 +129,6 @@ func NewViewBufferManager( beforeStart: beforeStart, refreshView: refreshView, onEndOfInput: onEndOfInput, - readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, onUIThread: onUIThread, @@ -136,17 +140,19 @@ func NewViewBufferManager( // (e.g. as the user scrolls down, back up, and down again) don't re-read lines // that have already been read: the task only ever reads the shortfall. func (self *ViewBufferManager) ReadLines(totalLines int) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} + readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} }) } } func (self *ViewBufferManager) ReadToEnd(then func()) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} + readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} }) } else if then != nil { then() @@ -220,7 +226,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix loadingMutex := deadlock.Mutex{} - self.readLines = make(chan LinesToRead, 1024) + readLines := make(chan LinesToRead, 1024) + self.readLines.Store(&readLines) scanner := bufio.NewScanner(r) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) @@ -312,7 +319,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix select { case <-opts.Stop: break outer - case linesToRead := <-self.readLines: + case linesToRead := <-readLines: callThen := func() { if linesToRead.Then != nil { linesToRead.Then() @@ -367,7 +374,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } } - self.readLines = nil + self.readLines.Store(nil) refreshViewIfStale() @@ -391,7 +398,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix close(lineWrittenChan) }) - self.readLines <- linesToRead + readLines <- linesToRead <-done @@ -509,7 +516,7 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.stopCurrentTask() } - self.readLines = nil + self.readLines.Store(nil) stop := make(chan struct{}) notifyStopped := make(chan struct{}) From e75688c101ce3e6b2a8c668a182f0e8c6c7aa1e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:39:00 +0200 Subject: [PATCH 04/22] Make the ViewBufferManager throttle flag atomic The throttle flag is set from the goroutine that watches a task for being stopped, and read when the next task starts up -- two different goroutines, so the plain bool field was a data race. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 50a94a15c..d58be3a92 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -88,8 +88,9 @@ type ViewBufferManager struct { // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we - // want to throttle the spawning of processes. - throttle bool + // want to throttle the spawning of processes. Atomic because it's set + // from one task's stop goroutine and read when the next task starts. + throttle atomic.Bool } type LinesToRead struct { @@ -177,7 +178,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix onFirstPageShown() } - if self.throttle { + if self.throttle.Load() { self.Log.Info("throttling task") time.Sleep(THROTTLE_TIME) } @@ -200,13 +201,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix case <-done: // The command finished and did not have to be preemptively stopped before the next command. // No need to throttle. - self.throttle = false + self.throttle.Store(false) case <-opts.Stop: // we use the time it took to start the program as a way of checking if things // are running slow at the moment. This is admittedly a crude estimate, but // the point is that we only want to throttle when things are running slow // and the user is flicking through a bunch of items. - self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD + self.throttle.Store(time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD) // Kill the still-running command. The only reason to do this is to save CPU usage // when flicking through several very long diffs when diff.algorithm = histogram is From f6eaed8cd4f2298fad4126ff56df77b71daed730 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:00:49 +0200 Subject: [PATCH 05/22] Snapshot the view width for command-task rendering on the UI thread A command task streams its output into a view from its own goroutine. To track soft-wraps (so cursor-positioning escapes from a pager land on the right line) the write path read the view's live InnerWidth, and the pty setup read its InnerSize -- both off the UI thread, racing the UI thread mutating the view's dimensions during layout. Capture the width on the UI thread instead and hand it to the task: the escape interpreter keeps a screenColMax it reads from, seeded in NewView and refreshed per render via View.SetContentWidth (called from newCmdTask/newPtyTask before the task's goroutine starts), and the pty size is computed in the after-layout callback rather than in the task's start func. The view's dimensions stay UI-thread-only; the task uses the snapshot rather than reading them live. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/escape.go | 14 +++++++++++--- pkg/gocui/view.go | 13 ++++++++++++- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 9 +++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index ad862a596..7f3de9e6e 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -34,6 +34,14 @@ type escapeInterpreter struct { // modelled — we don't track the col argument of CUPs, and most // pager-style emitters use col 1 anyway. screenRow, screenCol int + + // The screen width that soft-wraps are counted against (see + // notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on + // the UI thread (in NewView, and refreshed per render via + // View.SetContentWidth), rather than read live from the view's dimensions: + // a view's output is written from a task goroutine, and reading the live + // dimensions there would race the UI thread updating them during layout. + screenColMax int } type ( @@ -175,8 +183,8 @@ func (ei *escapeInterpreter) notifyColumnReset() { // columns; if that crosses the right edge of a `screenColMax`-wide pty // screen, the corresponding number of soft-wraps are added to screenRow // so subsequent CUPs land on the right line. -func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { - if screenColMax <= 0 { +func (ei *escapeInterpreter) notifyCellsWritten(width int) { + if ei.screenColMax <= 0 { return } // One column at a time: matches ConPTY's "pending wrap" semantics @@ -185,7 +193,7 @@ func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { // columns rather than doing the math in one shot so wide cells on a // row boundary still wrap cleanly. for range width { - if ei.screenCol > screenColMax { + if ei.screenCol > ei.screenColMax { ei.screenRow++ ei.screenCol = 1 } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index e4c5a5f98..ae8aea880 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -536,9 +536,20 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault v.InactiveViewSelBgColor = ColorDefault v.TitleColor, v.FrameColor = ColorDefault, ColorDefault + v.ei.screenColMax = v.InnerWidth() return v } +// SetContentWidth tells the view the screen width that content written to it +// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten). +// Callers pass the view's InnerWidth; it's a separate call, made on the UI +// thread when a render starts, so that the task goroutine that streams the +// content can consult this snapshot instead of reading the view's live +// dimensions (which the UI thread mutates during layout). +func (v *View) SetContentWidth(width int) { + v.ei.screenColMax = width +} + // Dimensions returns the dimensions of the View func (v *View) Dimensions() (int, int, int, int) { return v.x0, v.y0, v.x1, v.y1 @@ -907,7 +918,7 @@ func (v *View) write(p []byte) { for _, c := range cells { totalWidth += c.width } - v.ei.notifyCellsWritten(totalWidth, v.InnerWidth()) + v.ei.notifyCellsWritten(totalWidth) } } } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 1a774fc3d..d4f739c6d 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -93,9 +93,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Size the pty from the view's dimensions here, on the UI thread; the + // start func below runs on the task's goroutine, which must not read the + // view's live dimensions while the UI thread is laying it out. + cols, rows := gui.desiredPtySize(view) + var p oscommands.Pty start := func() (tasks.Cmd, io.Reader) { - cols, rows := gui.desiredPtySize(view) + // The pty (and pager) wrap to this width; apply it here, on the + // task's goroutine once the previous task has stopped, so it doesn't + // race that task's writes (see View.SetContentWidth). + view.SetContentWidth(width) + sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 9f9488048..27aacf58b 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Snapshot the view width here, on the UI thread, so the task goroutine + // doesn't read the view's live dimensions while it streams output. It's + // applied inside start() below rather than now, because start() runs once + // the previous task has stopped -- applying it here would race that task's + // still-running writes (see View.SetContentWidth). + contentWidth := view.InnerWidth() + var r io.ReadCloser start := func() (tasks.Cmd, io.Reader) { + view.SetContentWidth(contentWidth) + var err error r, err = cmd.StdoutPipe() if err != nil { From 65cb439076b665d458609f7f3a769786200029e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:01 +0200 Subject: [PATCH 06/22] Take the write mutex when clearing view lines and reading the buffer A view's line buffer, its viewLines/tainted flags, and its hover state are all written from the command-task goroutine (under writeMutex) as it renders. But three accessors reached that same state from the UI thread without the lock: SetView and the GUI-resize path cleared a view's lines directly, viewsToRedrawContentOnly read the tainted flag, and Buffer read the line buffer. Each raced a rendering task. Guard them with writeMutex, matching the view's other buffer accessors. These are reads/clears of state writeMutex already protects, not new callers of it -- the view's geometry stays outside the mutex. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 6 +++--- pkg/gocui/view.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 700c2b54c..936e1a310 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -385,7 +385,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er v.y1 = y1 if sizeChanged { - v.clearViewLines() + v.ClearViewLines() if v.Editable { cursorX, cursorY := v.TextArea.GetCursorXY() @@ -1461,7 +1461,7 @@ func (g *Gui) flush() error { // if GUI's size has changed, we need to redraw all views if maxX != g.maxX || maxY != g.maxY { for _, v := range g.views { - v.clearViewLines() + v.ClearViewLines() } } g.maxX, g.maxY = maxX, maxY @@ -1500,7 +1500,7 @@ func viewsToRedrawContentOnly(views []*View) []*View { redrawIndexes := set.New[int]() for i, v := range views { - if !v.tainted && !redrawIndexes.Includes(i) { + if !v.IsTainted() && !redrawIndexes.Includes(i) { continue } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ae8aea880..39f2d78a9 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -214,6 +214,16 @@ func (v *View) clearViewLines() { v.clearHover() } +// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on +// the UI thread (the layout pass) that touch a view whose content a task +// goroutine may be writing concurrently: viewLines/tainted/hover are all +// buffer state that writeMutex protects. +func (v *View) ClearViewLines() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + v.clearViewLines() +} + type searcher struct { searchString string searchPositions []SearchPosition @@ -1287,6 +1297,8 @@ func (v *View) updateSearchPositions() { // IsTainted tells us if the view is tainted func (v *View) IsTainted() bool { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() return v.tainted } @@ -1535,6 +1547,9 @@ func (v *View) BufferLines() []string { // Buffer returns a string with the contents of the view's internal // buffer. func (v *View) Buffer() string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + return linesToString(v.lines) } From eda215133072bfd135dae7c9001711358221e4b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:09 +0200 Subject: [PATCH 07/22] Handle a command task's end-of-input on the UI thread When a command task reaches EOF it runs onEndOfInput, which reads the view's line height (and thus its dimensions) to decide whether to scroll, sets the view's origin, and flushes stale cells. Reading the dimensions and setting the origin are UI-thread-only, but this ran on the task's own goroutine, racing the UI thread. Bounce onEndOfInput onto the UI thread, as we already do for the new-task origin reset. It's once per render, so it doesn't add the per-line UI-thread churn that streaming the content would. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index d58be3a92..3a964c838 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -353,8 +353,14 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content - self.onEndOfInput() + // so we're at the EOF and can flush the stale content. + // onEndOfInput reads the view's dimensions (to decide + // whether to scroll) and sets the origin, both of which + // are UI-thread-only, so run it there. + _ = self.onUIThread(func() error { + self.onEndOfInput() + return nil + }) callThen() break outer } From 9754a77b64439149a455ba91679ffe88efbebb3b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:08:07 +0200 Subject: [PATCH 08/22] Create popups and menus on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising a popup or menu pushes a context and mutates the popup views, so it must happen on the UI thread. But it can be triggered from a worker goroutine — for example a WithWaitingStatus handler that hits a merge conflict and calls PromptForConflictHandling, or a worker that shows a confirmation — where it raced the UI thread's layout and draw code. Bounce the creation onto the UI thread at the one point where the popup and menu producers are injected into the popup handler, so every caller stays oblivious to the threading. For a caller that is already on the UI thread this adds no delay: the main loop drains the enqueued closure in the same event-processing cycle, before it draws. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index ce70cb2d5..993798e42 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -812,13 +812,25 @@ func NewGui( gui.PopupHandler = popup.NewPopupHandler( cmn, + // Raising a popup or menu pushes a context and mutates the popup views, + // and it can be triggered from a worker goroutine (e.g. a + // WithWaitingStatus handler that hits a merge conflict and asks the user + // how to proceed). Bounce the creation onto the UI thread so it can't + // race the layout/draw code. Doing it here, at the one point where these + // producers are injected, keeps every caller oblivious to the threading. func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + gui.onUIThread(func() error { + gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + return nil + }) }, func() error { gui.c.Refresh(types.RefreshOptions{}); return nil }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, - gui.createMenu, + func(opts types.CreateMenuOptions) error { + gui.onUIThread(func() error { return gui.createMenu(opts) }) + return nil + }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) From 435e02efa83e8659e3befd2082852bc4df415a00 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:44:54 +0200 Subject: [PATCH 09/22] Remove the now-dead PopupMutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMutex guarded CurrentPopupOpts against a popup being created on a worker goroutine while the UI thread deactivated it, or reset it on a repo switch. Now that popup and menu creation is bounced onto the UI thread, every access to CurrentPopupOpts — create, deactivate, and the reset-on-switch (which already runs on the UI thread) — happens on the one goroutine, so the mutex protects nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/confirmation_helper.go | 7 ------- pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 1 - 3 files changed, 10 deletions(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 3663cd4ea..beffeb5e2 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -77,9 +77,7 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction( } func (self *ConfirmationHelper) DeactivateConfirmation() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Confirmation.Visible = false @@ -87,9 +85,7 @@ func (self *ConfirmationHelper) DeactivateConfirmation() { } func (self *ConfirmationHelper) DeactivatePrompt() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Prompt.Visible = false self.c.Views().Suggestions.Visible = false @@ -188,9 +184,6 @@ func characterForMask(mask bool) string { } func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - self.c.Mutexes().PopupMutex.Lock() - defer self.c.Mutexes().PopupMutex.Unlock() - _, cancel := goContext.WithCancel(ctx) // we don't allow interruptions of non-loader popups in case we get stuck somehow diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 993798e42..9b592ffae 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -620,9 +620,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { // setting this to nil so we don't get stuck based on a popup that was // previously opened - gui.Mutexes.PopupMutex.Lock() gui.State.CurrentPopupOpts = nil - gui.Mutexes.PopupMutex.Unlock() return gui.c.Context().Current() } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 5a256b434..6e7f72541 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -358,7 +358,6 @@ type Model struct { type Mutexes struct { SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex PtyMutex deadlock.Mutex } From 59ed1517bc1b3d42e91a5f65fc77ab427edbb5aa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:13 +0200 Subject: [PATCH 10/22] Wait for the event loop to exit in integration tests The test harness enqueued ErrQuit after a test finished, waited for the program to go idle, then slept a fixed second and declared "gocui should have already exited" if it hadn't. That fixed grace is fragile: under the race detector the shutdown legitimately takes longer than a second, so nearly every test failed with that message even though nothing was wrong. Wait for the main loop to actually return instead. gocui now closes a loopExited channel when MainLoop exits, and the harness blocks on it; the existing 40s watchdog still fails a test whose loop genuinely never quits. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 12 ++++++++++++ pkg/gui/test_mode.go | 7 ++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 936e1a310..dbef57b59 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -148,6 +148,10 @@ type Gui struct { maxX, maxY int outputMode OutputMode stop chan struct{} + // loopExited is closed when MainLoop returns, so callers (e.g. the + // integration-test harness) can wait for the event loop to actually finish + // rather than polling or sleeping a fixed interval. + loopExited chan struct{} // BgColor and FgColor allow to configure the background and foreground // colors of the GUI. @@ -260,6 +264,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.outputMode = opts.OutputMode g.stop = make(chan struct{}) + g.loopExited = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) g.userEvents = newUserEventQueue() @@ -348,6 +353,11 @@ func (g *Gui) Close() { Screen.Fini() } +// LoopExited returns a channel that is closed once MainLoop has returned. +func (g *Gui) LoopExited() <-chan struct{} { + return g.loopExited +} + // Size returns the terminal's size. func (g *Gui) Size() (x, y int) { return g.maxX, g.maxY @@ -965,6 +975,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + defer close(g.loopExited) + g.uiThreadID.Store(goid.Get()) go func() { diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 2d5958fbb..d6893c92d 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -40,11 +40,8 @@ func (gui *Gui) handleTestMode() { return gocui.ErrQuit }) - waitUntilIdle() - - time.Sleep(time.Second * 1) - - log.Fatal("gocui should have already exited") + // Wait for the event loop to actually exit. + <-gui.g.LoopExited() }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { From 33b8d497c22f131829b1b3181b9b19ce3800a80c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:22 +0200 Subject: [PATCH 11/22] Guard the patch builder against concurrent access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-patch git operations (move/pull/delete patch, and their rebase continuations) run on worker goroutines and call PatchBuilder.Reset when they've consumed the patch, clearing To and the fileInfoMap. Meanwhile the UI thread reads that state every layout — the options bar and the mode indicator both call Active() — so the reset raced the render. Add a mutex. The map's entries are only ever touched on the UI thread, so the lock only has to serialize the To field and the fileInfoMap pointer: readers snapshot the pointer under the lock and iterate the local, and getFileInfo drops the lock across its git diff I/O rather than holding it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/patch/patch_builder.go | 63 ++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index b730d9f62..0d5ca34f8 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/generics/maps" "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" ) @@ -50,6 +51,13 @@ type PatchBuilder struct { fileInfoMap map[string]*fileInfo Log *logrus.Entry + // mutex guards the fields that a git worker can mutate (via Reset, at the + // end of a patch-consuming operation) while the UI thread reads them to + // render — chiefly To and the fileInfoMap pointer. The map's *entries* are + // only ever touched on the UI thread, so we only hold the lock long enough + // to read or swap the fields, never across the git I/O in getFileInfo. + mutex deadlock.Mutex + // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc } @@ -62,6 +70,9 @@ func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBui } func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = to p.From = from p.reverse = reverse @@ -69,10 +80,21 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.fileInfoMap = map[string]*fileInfo{} } +// snapshotFileInfoMap returns the current fileInfoMap under the lock. The map's +// entries are only mutated on the UI thread, so callers can read the returned +// map without holding the lock; the lock only serializes the pointer swap that +// Reset/Start do (potentially from a git worker) against these reads. +func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.fileInfoMap +} + func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { var patch strings.Builder - for filename, info := range p.fileInfoMap { + for filename, info := range p.snapshotFileInfoMap() { if info.mode == UNSELECTED { continue } @@ -130,12 +152,17 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { } func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) { - info, ok := p.fileInfoMap[filename] + p.mutex.Lock() + fileInfoMap := p.fileInfoMap + from, to, reverse := p.From, p.To, p.reverse + p.mutex.Unlock() + + info, ok := fileInfoMap[filename] if ok { return info, nil } - diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true) + diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true) if err != nil { return nil, err } @@ -145,7 +172,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI previousPath: previousPath, } - p.fileInfoMap[filename] = info + fileInfoMap[filename] = info return info, nil } @@ -220,14 +247,16 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { } func (p *PatchBuilder) renderEachFilePatch(plain bool) []string { + fileInfoMap := p.snapshotFileInfoMap() + // sort files by name then iterate through and render each patch - filenames := maps.Keys(p.fileInfoMap) + filenames := maps.Keys(fileInfoMap) sort.Strings(filenames) patches := lo.Map(filenames, func(filename string, _ int) string { return p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, - PreviousPath: p.fileInfoMap[filename].previousPath, + PreviousPath: fileInfoMap[filename].previousPath, Plain: plain, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, @@ -245,11 +274,16 @@ func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string { } func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus { - if parent != p.To { + p.mutex.Lock() + to := p.To + fileInfoMap := p.fileInfoMap + p.mutex.Unlock() + + if parent != to { return UNSELECTED } - info, ok := p.fileInfoMap[filename] + info, ok := fileInfoMap[filename] if !ok { return UNSELECTED } @@ -267,16 +301,22 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin // clears the patch func (p *PatchBuilder) Reset() { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = "" p.fileInfoMap = map[string]*fileInfo{} } func (p *PatchBuilder) Active() bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return p.To != "" } func (p *PatchBuilder) IsEmpty() bool { - for _, fileInfo := range p.fileInfoMap { + for _, fileInfo := range p.snapshotFileInfoMap() { if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) { return false } @@ -287,9 +327,12 @@ func (p *PatchBuilder) IsEmpty() bool { // if any of these things change we'll need to reset and start a new patch func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return from != p.From || to != p.To || reverse != p.reverse } func (p *PatchBuilder) AllFilesInPatch() []string { - return lo.Keys(p.fileInfoMap) + return lo.Keys(p.snapshotFileInfoMap()) } From d48c8174d5d1a7af209c2a3935bc61719f610be1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:28 +0200 Subject: [PATCH 12/22] Refresh the patch-building panel on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch-building scope ran RefreshPatchBuildingPanel directly on the refresh worker, where it read the commit-files selection and set the patch view's origin off the UI thread — the latter raced the UI thread's draw. Bounce it onto the UI thread, exactly as the staging panel just above already does, guarded on the generation so a repo switch drops it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 768e7a618..39a8c2fe1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -398,7 +398,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PATCH_BUILDING) { - refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) + refresh("patch building", func() { + // Bounce onto the UI thread, like the staging panel above: + // RefreshPatchBuildingPanel reads the commit-files selection and + // sets the patch view's origin, neither of which may run off the UI + // thread. Guard on the generation so a repo switch mid-refresh drops + // it, like the model bounces. + self.onUIThreadUnlessRepoChanged(env, func() { + self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) + }) + }) } if scopeSet.Includes(types.MERGE_CONFLICTS) { From 1efcfcc1484ee2d4bdb8abc0c04219f1fcfe2577 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 10:50:13 +0200 Subject: [PATCH 13/22] Don't share a live view's buffer when copying its content moveMainContextToTop copies the current top view's content into the view it's promoting, to avoid a flicker. The source can be a main view with a live streaming task (e.g. resolving a conflict promotes the merge-conflicts view over a main view that's mid-diff), and CopyContent both read and published that source's buffer unsafely: - it read the source's lines/viewLines while locking only the destination, racing the task's concurrent Write; and - it aliased the source's row slices into the destination, so the source's ongoing appends (growslice reading the shared array) and refreshViewLinesIfNeeded's in-place wrapping-cache writes (&lines[i]) kept racing this view's rendering after the copy. Lock the source for the read, and shallow-clone the row slices so the destination gets its own arrays. The per-row cell data is immutable once written, so it stays shared -- the clone cost is proportional to the number of rows, not their contents. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 39f2d78a9..b106eb21f 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -7,6 +7,7 @@ package gocui import ( "fmt" "io" + "slices" "strings" "sync" "unicode" @@ -1146,10 +1147,25 @@ func (v *View) CopyContent(from *View) { v.writeMutex.Lock() defer v.writeMutex.Unlock() + // A background task may be streaming output into the source view's buffer + // via Write, so read it under its own lock. The source is always a + // different view than the destination (see the sole caller, + // moveMainContextToTop), and no other code holds two view write locks at + // once, so this can't deadlock. + from.writeMutex.Lock() + defer from.writeMutex.Unlock() + v.clear() - v.lines = from.lines - v.viewLines = from.viewLines + // Clone the row slices rather than sharing them: the source view stays + // live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded + // fills each row's wrapping cache in place via &lines[i]), so sharing the + // backing arrays would race those writes against this view's own rendering. + // This is a shallow clone -- the per-row cell data is immutable once written + // and stays shared, so the cost is proportional to the number of rows, not + // their contents. + v.lines = slices.Clone(from.lines) + v.viewLines = slices.Clone(from.viewLines) v.ox = from.ox v.oy = from.oy v.cx = from.cx From 1b0cc02e1e5106308ec0e1e4ae233987f3d50795 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:21:45 +0200 Subject: [PATCH 14/22] Refresh once when dropping multiple stash entries Dropping a range of stashes ran a refresh after each drop. A refresh issued from the UI thread does its git work on a worker and applies the model update in the background, so firing one per iteration let the workers race: an earlier drop's refresh (which read a stash list that still contained a later-dropped entry) could apply its result last, leaving the stash view showing an entry that git had already removed. Refresh once, after all the drops, so a single worker reads the final stash list. The indices are captured up front and dropped highest-first, so the remaining lower indices stay valid without an intervening refresh. It's also cheaper: one `git stash list` instead of one per entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/stash_controller.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 06e6991c6..a2b7e9e97 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,11 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.DropStash) + // Refresh once at the end rather than after each drop: an async + // refresh from the UI thread finishes in the background, so firing + // one per iteration lets the workers race and an earlier, stale + // result can land last. The indices are captured up front and we + // drop highest-first, so the remaining lower indices stay valid + // without an intervening refresh. + defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - err := self.c.Git().Stash.Drop(stashEntries[i].Index) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) - if err != nil { + if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { return err } } From d36ce5155968b318d0ecd3d3b068699410deb332 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 21:40:51 +0200 Subject: [PATCH 15/22] Capture suggestions inputs on the UI thread RefreshSuggestions dispatched to an AsyncHandler worker that read State.FindSuggestions and the prompt's TextArea (via GetPromptInput) from the worker goroutine. The main thread rewrites both in preparePromptPanel when it (re)creates a prompt panel, so an in-flight suggestions worker races those writes -- two data races surfaced under -race (filter_by_path/reword_commit_in_filtering_mode). Capture both on the UI thread (RefreshSuggestions is only ever called from UI-thread handlers) before dispatching to the worker. This is also more correct: we search for the input as it was when dispatched, which is what this request's AsyncHandler id corresponds to. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/suggestions_context.go | 11 +++++++++-- pkg/gui/editors.go | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index fb69b34d9..6f0b3eae6 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -81,10 +81,17 @@ func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) } func (self *SuggestionsContext) RefreshSuggestions() { + // Capture the suggestions function and the prompt input here, on the UI + // thread, rather than inside the worker below: the main thread rewrites both + // (State.FindSuggestions and the prompt's TextArea) when it (re)creates a + // prompt panel, so reading them from the worker races those writes. It's + // also more correct -- we search for the input as it was when dispatched, + // which is what this request's AsyncHandler id corresponds to. + findSuggestionsFn := self.State.FindSuggestions + promptInput := self.c.GetPromptInput() self.State.AsyncHandler.Do(func() func() { - findSuggestionsFn := self.State.FindSuggestions if findSuggestionsFn != nil { - suggestions := findSuggestionsFn(self.c.GetPromptInput()) + suggestions := findSuggestionsFn(promptInput) return func() { self.SetSuggestions(suggestions) } } return func() {} diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 7d3a93de3..37eacf416 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -35,10 +35,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool { v.RenderTextArea() suggestionsContext := gui.State.Contexts.Suggestions - if suggestionsContext.State.FindSuggestions != nil { + // Capture the suggestions function and the input here, on the UI thread; the + // main thread rewrites State.FindSuggestions when it (re)creates a prompt + // panel, so reading it from the worker below would race that write. + if findSuggestions := suggestionsContext.State.FindSuggestions; findSuggestions != nil { input := v.TextArea.GetContent() suggestionsContext.State.AsyncHandler.Do(func() func() { - suggestions := suggestionsContext.State.FindSuggestions(input) + suggestions := findSuggestions(input) return func() { suggestionsContext.SetSuggestions(suggestions) } }) } From 2a7b74d3f310ed33bb4635290358c860e74171af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 22:43:26 +0200 Subject: [PATCH 16/22] Don't access Model in refreshReflogCommits This was old code that was supposed to make a race less likely, but now that we capture model stuff on the UI thread we don't need it any more. --- pkg/gui/controllers/helpers/refresh_helper.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 39a8c2fe1..d0a0610e2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1313,10 +1313,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) { - // pulling state into its own variable in case it gets swapped out for another state - // and we get an out of bounds exception - model := self.c.Model() - // load does the git work on the worker and returns the new value for a // reflog slice, reading the existing slice (captured on the UI thread) for // the incremental fetch. The caller writes the result in the bounce. @@ -1352,8 +1348,8 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } self.onUIThreadUnlessRepoChanged(env, func() { - model.ReflogCommits = reflogCommits - model.FilteredReflogCommits = filteredReflogCommits + self.c.Model().ReflogCommits = reflogCommits + self.c.Model().FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and atomic with the list update. Setting the // selection doesn't scroll the view, so also reset the origin. From 25a3689c016955595a43a90410b235a775af1ae8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:19:03 +0200 Subject: [PATCH 17/22] Refresh the merge conflicts state on the UI thread The "merge conflicts" refresh scope ran on a worker like the others, but unlike them it does UI work rather than git work: RefreshMergeState reads the current context and renders (or escapes) the merge-conflicts view. Reading the context manager and rendering from a worker races the UI thread. Bounce it onto the UI thread with onUIThreadUnlessRepoChanged, exactly as the staging and patch-building scopes already do. Running on the UI thread also lets EscapeMerge push the files context directly instead of deferring the push to a separate UI task; it only needs to drop the merge-conflicts mutex first, because the push renders the newly focused file, which can take the mutex again. The deferred push could lose a race against the same refresh's prompt to continue the rebase/merge: if the prompt opened between RefreshMergeState and the deferred push, the push declined to cover the popup and was dropped, so closing the prompt landed the user in the emptied merge conflicts view. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 --- .../helpers/merge_conflicts_helper.go | 49 ++++++++----------- pkg/gui/controllers/helpers/refresh_helper.go | 10 +++- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 175bc3cc0..34ae285f0 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -51,32 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() { self.context().GetState().Reset() } -func (self *MergeConflictsHelper) EscapeMerge(background bool) error { - self.resetMergeState() +// EscapeMerge returns from the merge conflicts view to the files context. It +// must be called on the UI thread, without the merge-conflicts mutex held: +// pushing the files context renders the newly focused file to the main view, +// which can take the mutex again (via SetMergeState). +func (self *MergeConflictsHelper) EscapeMerge() { + self.ResetMergeState() - // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file - onUIThread := self.c.OnUIThread - if background { - // Reached from a background files refresh; keep it off the busy count - // (see the *Background dispatch methods) so it doesn't block a repo switch. - onUIThread = self.c.OnUIThreadBackground + // The files refresh may already have opened the prompt to continue the + // rebase/merge on top of us (if all conflicts are resolved); in that case + // don't push the files context over it. + if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } - onUIThread(func() error { - // There is a race condition here: refreshing the files scope can trigger the - // confirmation context to be pushed if all conflicts are resolved (prompting - // to continue the merge/rebase. In that case, we don't want to then push the - // files context over it. - // So long as both places call OnUIThread, we're fine. - if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil - }) - return nil } -func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) { - hasConflicts, err := self.setMergeStateWithoutLock(path) +// SetConflictsAndRender re-reads the file being merged and re-renders the +// merge conflicts view. Returns whether the file still has conflicts. +func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { + self.context().GetMutex().Lock() + defer self.context().GetMutex().Unlock() + + hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) if err != nil { return false, err } @@ -126,21 +122,18 @@ func (self *MergeConflictsHelper) Render() { }) } -func (self *MergeConflictsHelper) RefreshMergeState(background bool) error { - self.c.Contexts().MergeConflicts.GetMutex().Lock() - defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() - +func (self *MergeConflictsHelper) RefreshMergeState() error { if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY { return nil } - hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath()) + hasConflicts, err := self.SetConflictsAndRender() if err != nil { return err } if !hasConflicts { - return self.EscapeMerge(background) + self.EscapeMerge() } return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d0a0610e2..4fedf9c7d 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -411,7 +411,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) + refresh("merge conflicts", func() { + // Bounce onto the UI thread, like the staging and patch-building + // panels above: RefreshMergeState reads the current context and + // renders (or escapes) the merge-conflicts view, none of which may + // run off the UI thread. + self.onUIThreadUnlessRepoChanged(env, func() { + _ = self.mergeConflictsHelper.RefreshMergeState() + }) + }) } self.refreshStatus(env) From 9f2886f96f75f0c127212562e05cc4d53e49b147 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:37:38 +0200 Subject: [PATCH 18/22] Hold the file-path suggestions trie outside the model The file-path suggestions trie is rebuilt asynchronously and then read by the suggestions search, which runs on an AsyncHandler worker. It lived in Model().FilesTrie, so that worker read the (UI-thread-only) model. Move it to an atomic pointer on the SuggestionsHelper instead: it's the only place that uses it, the helper is recreated per repo (so the cache still resets on a repo switch), and an atomic pointer is safe to store from the build and load from the search worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/suggestions_helper.go | 22 +++++++++++++------ pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 4 ---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index 8a5916816..8784d82fc 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -3,6 +3,7 @@ package helpers import ( "fmt" "strings" + "sync/atomic" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -28,14 +29,20 @@ import ( type SuggestionsHelper struct { c *HelperCommon + + // filesTrie holds the repo's file paths for file-path suggestions. It's + // rebuilt asynchronously and read from the suggestions worker goroutine, so + // it lives here as an atomic pointer rather than in the (UI-thread-only) + // model. + filesTrie atomic.Pointer[patricia.Trie] } func NewSuggestionsHelper( c *HelperCommon, ) *SuggestionsHelper { - return &SuggestionsHelper{ - c: c, - } + self := &SuggestionsHelper{c: c} + self.filesTrie.Store(patricia.NewTrie()) + return self } func (self *SuggestionsHelper) getRemoteNames() []string { @@ -137,9 +144,9 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type trie.Insert(patricia.Prefix(file), file) } + // cache the trie for future use + self.filesTrie.Store(trie) self.c.OnUIThread(func() error { - // cache the trie for future use - self.c.Model().FilesTrie = trie self.c.Contexts().Suggestions.RefreshSuggestions() return nil }) @@ -148,9 +155,10 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type }) return func(input string) []*types.Suggestion { + filesTrie := self.filesTrie.Load() matchingNames := []string{} if self.c.UserConfig().Gui.UseFuzzySearch() { - _ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { + _ = filesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { matchingNames = append(matchingNames, item.(string)) return nil }) @@ -159,7 +167,7 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type matchingNames = utils.FilterStrings(input, matchingNames, true) } else { substrings := strings.Fields(input) - _ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { + _ = filesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { for _, sub := range substrings { if !utils.CaseAwareContains(item.(string), sub) { return nil diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 9b592ffae..533c01fbd 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -49,7 +49,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) const StartupPopupVersion = 5 @@ -639,7 +638,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { FilteredReflogCommits: make([]*models.Commit, 0), ReflogCommits: make([]*models.Commit, 0), BisectInfo: git_commands.NewNullBisectInfo(), - FilesTrie: patricia.NewTrie(), Authors: map[string]*models.Author{}, MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), HashPool: &utils.StringPool{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6e7f72541..6d11e29db 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -11,7 +11,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) type HelperCommon struct { @@ -348,9 +347,6 @@ type Model struct { MainBranches *git_commands.MainBranches - // for displaying suggestions while typing in a file name - FilesTrie *patricia.Trie - Authors map[string]*models.Author HashPool *utils.StringPool From 87ef96974e26ba31b34eae4acfc5d1365a86ddc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:42:24 +0200 Subject: [PATCH 19/22] Check for exec todos on the UI thread hasExecTodos reads Model().Commits. genericMergeCommandImpl evaluates it when deciding whether to use a subprocess, and on the recursive auto-skip path that runs on a worker -- so the read raced the UI thread. Bounce it onto the UI thread there, keyed off the calledFromWorker flag the function already carries (on the UI-thread entry path the read stays inline). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index a8696a21c..7c7ab3e9a 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -127,7 +127,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || // but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build // tasks whose output the user will want to see in the terminal - (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos()) + (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker)) if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction @@ -168,16 +168,31 @@ func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehav return types.KeepCommitSelectionByHash } -func (self *MergeAndRebaseHelper) hasExecTodos() bool { - for _, commit := range self.c.Model().Commits { - if !commit.IsTODO() { - break - } - if commit.Action == todo.Exec { - return true +func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool { + check := func() bool { + for _, commit := range self.c.Model().Commits { + if !commit.IsTODO() { + break + } + if commit.Action == todo.Exec { + return true + } } + return false } - return false + + // This reads the model, which is only safe on the UI thread, so bounce there + // when we're being called from a worker. + if !calledFromWorker { + return check() + } + + result := false + _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + result = check() + return nil + }) + return result } var conflictStrings = []string{ From c23bcd6d9423f3fd51bd8b8d39a5b01dbc52ca65 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 15:19:55 +0200 Subject: [PATCH 20/22] Store the UI thread ID earlier --- pkg/gocui/gui.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index dbef57b59..20beb2af7 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -293,6 +293,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.playRecording = opts.PlayRecording + // Record the UI thread here, at construction. This assumes NewGui is called + // on the same goroutine that will run MainLoop, which holds for all our + // callers -- and it means IsUIThread is already correct for the UI work that + // runs during startup, before we reach MainLoop. + g.uiThreadID.Store(goid.Get()) + return g, nil } @@ -977,8 +983,6 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { func (g *Gui) MainLoop() error { defer close(g.loopExited) - g.uiThreadID.Store(goid.Get()) - go func() { for { select { From e299de32700ab1a2d865652b6375a6c1131ec76c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:11:07 +0200 Subject: [PATCH 21/22] Assert that Model() and Context() are only accessed on the UI thread The bounce model requires that a worker never touch UI-thread-owned state: it should capture what it needs on the UI thread and pass that in. Guard the two central accessors -- Model() (the git model) and Context() (the context manager, which owns the mutable current-context/stack) -- with a debug-only panic when they're called off the UI thread. Since the integration tests run with -debug, a stray worker access now fails deterministically and points at itself, rather than surfacing later as a probabilistic data race. One supporting change make the assertion usable: the integration test driver inspects gui state from the test goroutine, so GuiDriver.CurrentContext reads the context manager directly rather than through the now-guarded c.Context(). Contexts() (the registry of context objects) is deliberately left unguarded: workers legitimately fetch a context to grab its mutex or check identity, so a blanket assertion there would flag safe accesses. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui_common.go | 12 ++++++++++++ pkg/gui/gui_driver.go | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 69ec44781..80b2b9ded 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -58,7 +58,18 @@ func (self *guiCommon) PauseBackgroundRefreshes(pause bool) { self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause) } +// assertOnUIThread panics (in debug builds) if called from a worker goroutine. +// Use it to guard accessors for state that only the UI thread may touch, so +// that a stray worker access fails deterministically -- and points at itself -- +// rather than surfacing later as a probabilistic data race. +func (self *guiCommon) assertOnUIThread(accessor string) { + if self.GetConfig().GetDebug() && !self.GocuiGui().IsUIThread() { + panic(accessor + " accessed from a worker") + } +} + func (self *guiCommon) Context() types.IContextMgr { + self.assertOnUIThread("Context()") return self.gui.State.ContextMgr } @@ -113,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes { } func (self *guiCommon) Model() *types.Model { + self.assertOnUIThread("Model()") return self.gui.State.Model } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 31094b253..7bd31d93d 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -92,7 +92,10 @@ func (self *GuiDriver) Keys() config.KeybindingConfig { } func (self *GuiDriver) CurrentContext() types.Context { - return self.gui.c.Context().Current() + // Read the context manager directly rather than through c.Context(): the + // driver runs on the test goroutine, not the UI thread, so it must bypass + // the UI-thread assertion that accessor carries. + return self.gui.State.ContextMgr.Current() } func (self *GuiDriver) ContextForView(viewName string) types.Context { From 5769ab219069daafbbdba09c2e8529cd17d250f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:16:25 +0200 Subject: [PATCH 22/22] Don't retry failed integration tests Now that we solved all known concurrency issues and our tests should be 100% deterministic, reduce MaxAttempts to 1 so that tests fail immediately. We don't want to paper over existing flakiness any more. --- pkg/integration/clients/go_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 211e73d28..11f6e754e 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -56,7 +56,7 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 2, + MaxAttempts: 1, }) assert.NoError(t, err)