Don't run end-of-input handling for a render that was stopped

When a task is stopped to make way for a newer one, stopping closes
opts.Stop, and the scanner goroutine then closes lineChan. The read loop's
select between those two channels is therefore non-deterministic: it can
land on the closed lineChan (ok == false) instead of the opts.Stop case,
sending a stopped task into the end-of-input branch.

There it runs the full finalize — swapping its half-read off-screen buffer
in, clamping the origin to the truncated content, and clearing the loading
flag — all of which corrupt what the incoming task is about to render. The
most visible symptom is a brief frame of truncated content with the scroll
yanked to the top, seen when re-renders overlap rapidly (e.g. the periodic
background refresh re-rendering a main view faster than it can load, very
easy to hit under LAZYGIT_SLOW_RENDER).

The underlying bug predates the off-screen render (the EOF branch always
clamped the origin via onEndOfInput), but that change made it far worse by
also swapping a truncated buffer into the display. Fix it at the source: in
the EOF branch, check whether we were stopped and, if so, bail out like the
explicit stop case, leaving the view entirely to the task that replaces us.

There's no test because the bug is the non-deterministic select itself:
any test would have to win a coin flip to observe it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-08-15 15:30:27 +02:00
co-authored by Claude Opus 5
parent 9e23111172
commit bf6c34798c
+19 -3
View File
@@ -422,9 +422,25 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
loadingMutex.Unlock()
if !ok {
// We're at EOF before reaching InitialRefreshAfter (the content was
// shorter than a screenful), so swap in whatever we read now, and
// flush the stale content.
// lineChan is closed. At a genuine end of input we swap in what we
// read and finalize. But lineChan is also closed when this task has
// been stopped to make way for a newer one: stopping closes
// opts.Stop, and the scanner goroutine then closes lineChan, so the
// select above can land here instead of on the opts.Stop case. A
// stopped task is being replaced and must leave the view to the
// incoming task — swapping in its half-read buffer, clamping the
// origin, or clearing `loading` would all corrupt what that task is
// about to render. So bail out here, the same as the explicit stop
// case above.
select {
case <-opts.Stop:
callThen()
break outer
default:
}
// Genuine end of input: swap in whatever we read (the content was
// shorter than a screenful, so we never hit the InitialRefreshAfter
// swap), and 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.