From bf6c34798ccdde12682954b66e11f052554bcab7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 22:33:43 +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, 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) --- pkg/tasks/tasks.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 918713a05..6a7e828f4 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -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.