1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-12-12 11:15:00 +02:00
lazygit/docs/dev/Busy.md
Jesse Duffield 14ecc15e71 Use first class task objects instead of global counter
The global counter approach is easy to understand but it's brittle and depends on implicit behaviour that is not very discoverable.

With a global counter, if any goroutine accidentally decrements the counter twice, we'll think lazygit is idle when it's actually busy.
Likewise if a goroutine accidentally increments the counter twice we'll think lazygit is busy when it's actually idle.
With the new approach we have a map of tasks where each task can either be busy or not. We create a new task and add it to the map
when we spawn a worker goroutine (among other things) and we remove it once the task is done.

The task can also be paused and continued for situations where we switch back and forth between running a program and asking for user
input.

In order for this to work with `git push` (and other commands that require credentials) we need to obtain the task from gocui when
we create the worker goroutine, and then pass it along to the commands package to pause/continue the task as required. This is
MUCH more discoverable than the old approach which just decremented and incremented the global counter from within the commands package,
but it's at the cost of expanding some function signatures (arguably a good thing).

Likewise, whenever you want to call WithWaitingStatus or WithLoaderPanel the callback will now have access to the task for pausing/
continuing. We only need to actually make use of this functionality in a couple of places so it's a high price to pay, but I don't
know if I want to introduce a WithWaitingStatusTask and WithLoaderPanelTask function (open to suggestions).
2023-07-09 21:30:19 +10:00

4.6 KiB

Knowing when Lazygit is busy/idle

The use-case

This topic deserves its own doc because there there are a few touch points for it. We have a use-case for knowing when Lazygit is idle or busy because integration tests follow the following process:

  1. press a key
  2. wait until Lazygit is idle
  3. run assertion / press another key
  4. repeat

In the past the process was:

  1. press a key
  2. run assertion
  3. if assertion fails, wait a bit and retry
  4. repeat

The old process was problematic because an assertion may give a false positive due to the contents of some view not yet having changed since the last key was pressed.

The solution

First, it's important to distinguish three different types of goroutines:

  • The UI goroutine, of which there is only one, which infinitely processes a queue of events
  • Worker goroutines, which do some work and then typically enqueue an event in the UI goroutine to display the results
  • Background goroutines, which periodically spawn worker goroutines (e.g. doing a git fetch every minute)

The point of distinguishing worker goroutines from background goroutines is that when any worker goroutine is running, we consider Lazygit to be 'busy', whereas this is not the case with background goroutines. It would be pointless to have background goroutines be considered 'busy' because then Lazygit would be considered busy for the entire duration of the program!

In gocui, the underlying package we use for managing the UI and events, we keep track of how many busy goroutines there are using the Task type. A task represents some work being done by lazygit. The gocui Gui struct holds a map of tasks and allows creating a new task (which adds it to the map), pausing/continuing a task, and marking a task as done (which removes it from the map). Lazygit is considered to be busy so long as there is at least one busy task in the map; otherwise it's considered idle. When Lazygit goes from busy to idle, it notifies the integration test.

It's important that we play by the rules below to ensure that after the user does anything, all the processing that follows happens in a contiguous block of busy-ness with no gaps.

Spawning a worker goroutine

Here's the basic implementation of OnWorker (using the same flow as WaitGroups):

func (g *Gui) OnWorker(f func(*Task)) {
	task := g.NewTask()
	go func() {
		f(task)
		task.Done()
	}()
}

The crucial thing here is that we create the task before spawning the goroutine, because it means that we'll have at least one busy task in the map until the completion of the goroutine. If we created the task within the goroutine, the current function could exit and Lazygit would be considered idle before the goroutine starts, leading to our integration test prematurely progressing.

You typically invoke this with self.c.OnWorker(f). Note that the callback function receives the task. This allows the callback to pause/continue the task (see below).

Spawning a background goroutine

Spawning a background goroutine is as simple as:

go utils.Safe(f)

Where utils.Safe is a helper function that ensures we clean up the gui if the goroutine panics.

Programmatically enqueing a UI event

This is invoked with self.c.OnUIThread(f). Internally, it creates a task before enqueuing the function as an event (including the task in the event struct) and once that event is processed by the event queue (and any other pending events are processed) the task is removed from the map by calling task.Done().

Pressing a key

If the user presses a key, an event will be enqueued automatically and a task will be created before (and Done'd after) the event is processed.

Special cases

There are a couple of special cases where we manually pause/continue the task directly in the client code. These are subject to change but for the sake of completeness:

Writing to the main view(s)

If the user focuses a file in the files panel, we run a git diff command for that file and write the output to the main view. But we only read enough of the command's output to fill the view's viewport: further loading only happens if the user scrolls. Given that we have a background goroutine for running the command and writing more output upon scrolling, we create our own task and call Done on it as soon as the viewport is filled.

Requesting credentials from a git command

Some git commands (e.g. git push) may request credentials. This is the same deal as above; we use a worker goroutine and manually pause continue its task as we go from waiting on the git command to waiting on user input. This requires passing the task through to the Push method so that it can be paused/continued.