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, applying the saved scroll, 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-09 23:11:25 +02:00
parent 63dac0d77d
commit 0e3d3d3182

View file

@ -479,10 +479,26 @@ 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. Apply
// the saved scroll first (if any) so that onEndOfInput clamps it back
// into range when the new content turned out shorter than expected.
// 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, applying the
// saved scroll, 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:
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). Apply the saved scroll first (if any) so that onEndOfInput
// clamps it back into range when the content turned out shorter than
// expected.
// 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.