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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-08 08:08:47 +02:00
parent 99c1bcbf23
commit 40868a9389

View file

@ -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{})