From 0e3d3d318202305f6399ed765668b1614eb05ed4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 9 Jun 2026 23:11:25 +0200 Subject: [PATCH] Don't run end-of-input handling for a render that was stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/tasks/tasks.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 20c0c9600..56c7a59bf 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -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.