jesseduffield.lazygit/pkg/tasks/tasks.go
Stefan Haller f9b790a1f9 Let OnUIThreadAndWait's error be about the wait, not about f
Every caller passes an f that unconditionally returns nil, so f's error
return has never carried anything: the value is dead weight, and it
occupies the one channel the wait itself needs to report that it couldn't
run f at all. Drop it, so that the error the wait returns can only ever
mean that.

Work that can fail hands its error back through a captured variable, the
way the background fetch already hands back four values, which keeps the
two outcomes distinguishable at a call site that has both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:04:53 +02:00

545 lines
16 KiB
Go

package tasks
import (
"bufio"
"fmt"
"io"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
// Cmd abstracts over a started external process. *exec.Cmd satisfies the bulk
// of it via ExecCmd, but pty implementations can supply their own types — on
// Windows, ConPTY has to spawn via CreateProcess directly and can't use
// *exec.Cmd (see golang/go#62708).
type Cmd interface {
Wait() error
String() string
// Terminate makes the process stop early, as gracefully as the platform
// allows. It doesn't wait for the process to exit.
Terminate() error
}
// ExecCmd adapts *exec.Cmd to Cmd.
type ExecCmd struct {
*exec.Cmd
}
// Terminate sends SIGTERM on Unix. On Windows it does nothing, so a stopped
// command keeps running until it next writes to its (by then closed) output
// pipe.
func (c ExecCmd) Terminate() error {
return oscommands.TerminateProcessGracefully(c.Process)
}
// This file revolves around running commands that will be output to the main panel
// in the gui. If we're flicking through the commits panel, we want to invoke a
// `git show` command for each commit, but we don't want to read the entire output
// at once (because that would slow things down); we just want to fill the panel
// and then read more as the user scrolls down. We also want to ensure that we're only
// ever running one `git show` command at time, and that we only have one command
// writing its output to the main panel at a time.
const THROTTLE_TIME = time.Millisecond * 30
// we use this to check if the system is under stress right now. Hopefully this makes sense on other machines
const COMMAND_START_THRESHOLD = time.Millisecond * 10
type ViewBufferManager struct {
// this blocks until the task has been properly stopped
stopCurrentTask func()
// this is what we write the output of the task to. It's typically a view
writer io.Writer
waitingMutex deadlock.Mutex
taskIDMutex deadlock.Mutex
Log *logrus.Entry
newTaskID int
// 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()
refreshView func()
onEndOfInput func()
// see docs/dev/Busy.md
// A gocui task is not the same thing as the tasks defined in this file.
// A gocui task simply represents the fact that lazygit is busy doing something,
// whereas the tasks in this file are about rendering content to a view.
newGocuiTask func() gocui.Task
// Runs f on the UI thread and blocks until it has completed. All mutations
// of the view happen through this, so that the view is only ever touched on
// the UI thread (where it is also laid out and drawn), never on the task's
// own goroutine.
onUIThread func(f func()) error
// if the user flicks through a heap of items, with each one
// spawning a process to render something to the main view,
// it can slow things down quite a bit. In these situations we
// want to throttle the spawning of processes. Atomic because it's set
// from one task's stop goroutine and read when the next task starts.
throttle atomic.Bool
}
type LinesToRead struct {
// The total number of lines the task should have read once this request is
// satisfied. This is an absolute count from the start of the task, not a
// delta: the task keeps track of how many lines it has already read and only
// reads the shortfall, so a request for a total at or below what has already
// been read reads nothing. -1 means read all the way to the end.
Total int
// Number of lines after which we have read enough to fill the view, and can
// do an initial refresh. Only set for the initial read request; -1 for
// subsequent requests.
InitialRefreshAfter int
// Function to call after reading the lines is done
Then func()
}
func (self *ViewBufferManager) GetTaskKey() string {
return self.taskKey
}
func NewViewBufferManager(
log *logrus.Entry,
writer io.Writer,
beforeStart func(),
refreshView func(),
onEndOfInput func(),
onNewKey func(),
newGocuiTask func() gocui.Task,
onUIThread func(f func()) error,
) *ViewBufferManager {
return &ViewBufferManager{
Log: log,
writer: writer,
beforeStart: beforeStart,
refreshView: refreshView,
onEndOfInput: onEndOfInput,
onNewKey: onNewKey,
newGocuiTask: newGocuiTask,
onUIThread: onUIThread,
}
}
// ReadLines asks the task to ensure it has read at least totalLines lines in
// total. Because the count is absolute rather than a delta, repeated requests
// (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 ch := self.readLines.Load(); ch != nil {
readLines := *ch
go utils.Safe(func() {
readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1}
})
}
}
func (self *ViewBufferManager) ReadToEnd(then func()) {
if ch := self.readLines.Load(); ch != nil {
readLines := *ch
go utils.Safe(func() {
readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then}
})
} else if then != nil {
then()
}
}
func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error {
return func(opts TaskOpts) error {
var onDoneOnce sync.Once
var onFirstPageShownOnce sync.Once
onFirstPageShown := func() {
onFirstPageShownOnce.Do(func() {
opts.InitialContentLoaded()
})
}
onDone := func() {
if onDoneFn != nil {
onDoneOnce.Do(onDoneFn)
}
onFirstPageShown()
}
if self.throttle.Load() {
self.Log.Info("throttling task")
time.Sleep(THROTTLE_TIME)
}
select {
case <-opts.Stop:
onDone()
return nil
default:
}
startTime := time.Now()
cmd, r := start()
timeToStart := time.Since(startTime)
done := make(chan struct{})
go utils.Safe(func() {
select {
case <-done:
// The command finished and did not have to be preemptively stopped before the next command.
// No need to throttle.
self.throttle.Store(false)
case <-opts.Stop:
// we use the time it took to start the program as a way of checking if things
// are running slow at the moment. This is admittedly a crude estimate, but
// the point is that we only want to throttle when things are running slow
// and the user is flicking through a bunch of items.
self.throttle.Store(time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD)
// Kill the still-running command. The only reason to do this is to save CPU usage
// when flicking through several very long diffs when diff.algorithm = histogram is
// being used, in which case multiple git processes continue to calculate expensive
// diffs in the background even though they have been stopped already.
if err := cmd.Terminate(); err != nil {
self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String())
}
// close the task's stdout pipe (or the pty if we're using one) to make the command terminate
onDone()
}
})
loadingMutex := deadlock.Mutex{}
readLines := make(chan LinesToRead, 1024)
self.readLines.Store(&readLines)
scanner := bufio.NewScanner(r)
scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize))
lineChan := make(chan []byte)
lineWrittenChan := make(chan struct{})
// We're reading from the scanner in a separate goroutine because on windows
// if running git through a shim, we sometimes kill the parent process without
// killing its children, meaning the scanner blocks forever. This solution
// leaves us with a dead goroutine, but it's better than blocking all
// rendering to main views.
go utils.Safe(func() {
defer close(lineChan)
for scanner.Scan() {
select {
case <-opts.Stop:
return
case lineChan <- scanner.Bytes():
// We need to confirm the data has been fed into the view before we
// pull more from the scanner because the scanner uses the same backing
// array and we don't want to be mutating that while it's being written
<-lineWrittenChan
}
}
if err := scanner.Err(); err != nil {
self.Log.Error(err)
}
})
loaded := false
go utils.Safe(func() {
ticker := time.NewTicker(time.Millisecond * 200)
defer ticker.Stop()
select {
case <-opts.Stop:
return
case <-ticker.C:
loadingMutex.Lock()
if !loaded {
self.beforeStart()
_, _ = self.writer.Write([]byte("loading..."))
self.refreshView()
}
loadingMutex.Unlock()
}
})
go utils.Safe(func() {
isViewStale := true
writeToView := func(content []byte) {
isViewStale = true
_, _ = self.writer.Write(content)
}
refreshViewIfStale := func() {
if isViewStale {
self.refreshView()
isViewStale = false
}
}
// Go's select picks randomly among ready cases, so once opts.Stop is
// closed the selects below could still service a ready data channel
// instead of bailing. Check stop explicitly first to give it priority:
// a task that's been stopped (it's being replaced by a newer one) must
// not touch the view here — beforeStart clears it and the prefix gets
// written, clobbering what the incoming task is about to render.
stopped := func() bool {
select {
case <-opts.Stop:
return true
default:
return false
}
}
// The total number of lines we have read so far. Requests specify an
// absolute target total (see LinesToRead.Total), so we compare against
// this to work out how many more lines, if any, we still need to read.
linesRead := 0
outer:
for {
if stopped() {
break outer
}
select {
case <-opts.Stop:
break outer
case linesToRead := <-readLines:
callThen := func() {
if linesToRead.Then != nil {
linesToRead.Then()
}
}
for linesToRead.Total == -1 || linesRead < linesToRead.Total {
if stopped() {
callThen()
break outer
}
var ok bool
var line []byte
select {
case <-opts.Stop:
callThen()
break outer
case line, ok = <-lineChan:
// process line below
}
loadingMutex.Lock()
if !loaded {
self.beforeStart()
if prefix != "" {
writeToView([]byte(prefix))
}
loaded = true
}
loadingMutex.Unlock()
if !ok {
// if we're here then there's nothing left to scan from the source
// so we're at the EOF and can 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.
_ = self.onUIThread(self.onEndOfInput)
callThen()
break outer
}
writeToView(append(line, '\n'))
lineWrittenChan <- struct{}{}
linesRead++
if linesRead == linesToRead.InitialRefreshAfter {
// We have read enough lines to fill the view, so do a first refresh
// here to show what we have. Continue reading and refresh again at
// the end to make sure the scrollbar has the right size.
refreshViewIfStale()
}
}
refreshViewIfStale()
onFirstPageShown()
callThen()
}
}
self.readLines.Store(nil)
refreshViewIfStale()
select {
case <-opts.Stop:
// If we stopped the task, don't block waiting for it; this could cause a delay if
// the process takes a while until it actually terminates. We still want to call
// Wait to reclaim any resources, but do it on a background goroutine, and ignore
// any errors.
go func() { _ = cmd.Wait() }()
default:
if err := cmd.Wait(); err != nil {
self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v", err, cmd.String())
}
}
// calling this here again in case the program ended on its own accord
onDone()
close(done)
close(lineWrittenChan)
})
readLines <- linesToRead
<-done
return nil
}
}
// Close closes the task manager, killing whatever task may currently be running
func (self *ViewBufferManager) Close() {
// stopCurrentTask is written by NewTask's goroutine under waitingMutex (and
// so is the sync.Once it closes over), so read it under the lock and call
// the captured value; a task starting on shutdown must not race us here.
self.waitingMutex.Lock()
stopCurrentTask := self.stopCurrentTask
self.waitingMutex.Unlock()
if stopCurrentTask == nil {
return
}
c := make(chan struct{})
go utils.Safe(func() {
stopCurrentTask()
c <- struct{}{}
})
select {
case <-c:
return
case <-time.After(3 * time.Second):
fmt.Println("cannot kill child process")
}
}
// different kinds of tasks:
// 1) command based, where the manager can be asked to read more lines, but the command can be killed
// 2) string based, where the manager can also be asked to read more lines
type TaskOpts struct {
// Channel that tells the task to stop, because another task wants to run.
Stop chan struct{}
// Only for tasks which are long-running, where we read more lines sporadically.
// We use this to keep track of when a user's action is complete (i.e. all views
// have been refreshed to display the results of their action)
InitialContentLoaded func()
}
func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error {
gocuiTask := self.newGocuiTask()
var completeTaskOnce sync.Once
completeGocuiTask := func() {
completeTaskOnce.Do(func() {
gocuiTask.Done()
})
}
// Assign the taskID synchronously so it reflects NewTask call order
// rather than the order in which the spawned goroutines happen to be
// scheduled. Otherwise two NewTask calls in quick succession can have
// their goroutines race, with the later-called task ending up with the
// lower taskID and losing the staleness check below.
self.taskIDMutex.Lock()
self.newTaskID++
taskID := self.newTaskID
self.taskIDMutex.Unlock()
go utils.Safe(func() {
defer completeGocuiTask()
self.taskIDMutex.Lock()
// Bail out before touching shared view state if a newer task has
// already been queued: if we reset the view here we'd do it for a task
// that's about to exit, potentially wiping output the winning task has
// already written.
if taskID < self.newTaskID {
self.taskIDMutex.Unlock()
return
}
resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil
self.taskKey = key
self.taskIDMutex.Unlock()
if resetOrigin {
// onNewKey resets the view's scroll origin, which is view state the
// UI thread reads while laying out and drawing, so do it there. This
// must happen after releasing taskIDMutex: it blocks until the UI
// thread runs it, and a NewTask call on the UI thread takes
// taskIDMutex, so holding it here would deadlock.
_ = self.onUIThread(self.onNewKey)
}
self.waitingMutex.Lock()
// Re-check staleness after acquiring waitingMutex: a newer task
// may have arrived while we were blocked here.
self.taskIDMutex.Lock()
if taskID < self.newTaskID {
self.waitingMutex.Unlock()
self.taskIDMutex.Unlock()
return
}
self.taskIDMutex.Unlock()
if self.stopCurrentTask != nil {
self.stopCurrentTask()
}
self.readLines.Store(nil)
stop := make(chan struct{})
notifyStopped := make(chan struct{})
var once sync.Once
onStop := func() {
close(stop)
<-notifyStopped
}
self.stopCurrentTask = func() { once.Do(onStop) }
self.waitingMutex.Unlock()
if err := f(TaskOpts{Stop: stop, InitialContentLoaded: completeGocuiTask}); err != nil {
self.Log.Error(err) // might need an onError callback
}
close(notifyStopped)
})
return nil
}