diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b9815d44..e3cf63bbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,15 @@ on: description: 'Version bump type' type: choice required: true - default: 'patch' + default: 'minor (normal)' options: - - minor - - patch + - minor (normal) + - patch (hotfix) + branch: + description: 'Branch to release from' + type: string + required: true + default: 'master' ignore_blocks: description: 'Ignore blocking PRs/issues' type: boolean @@ -49,12 +54,13 @@ jobs: uses: actions/checkout@v7 with: repository: jesseduffield/lazygit + ref: ${{ inputs.branch }} token: ${{ secrets.LAZYGIT_RELEASE_PAT }} fetch-depth: 0 - name: Get Latest Tag run: | - latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0") + latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0") if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: Tag format is invalid. Expected format: vX.X.X" @@ -121,7 +127,7 @@ jobs: IFS='.' read -r major minor patch <<< "$LATEST_TAG" if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$VERSION_BUMP" == "patch" ]]; then + if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then patch=$((patch + 1)) else minor=$((minor + 1)) @@ -151,7 +157,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$NEW_TAG" -a -m "Release $NEW_TAG" - git push origin "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" - name: Setup Go uses: actions/setup-go@v6 diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 6bc3b7d19..20d31c11c 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -25,8 +25,9 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ - log: log, - innerRunner: runner, + log: log, + innerRunner: runner, + initialRetryDelay: defaultInitialRetryDelay, } }) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index 668feef93..8112b0f30 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -11,20 +11,42 @@ import ( // here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock const ( - WaitTime = 50 * time.Millisecond - RetryCount = 5 + // defaultInitialRetryDelay is how long we wait before the first retry of a + // command that failed with a transient lock error. We double it before each + // subsequent retry (see retryOnLockError), so across maxRetries attempts we + // wait for a bit over a second in total. That's long enough to outlast the + // brief window during which another git process holds a lock we need — + // typically our own foreground `git status` refresh, which takes index.lock + // to persist its refreshed stat-cache. + defaultInitialRetryDelay = 20 * time.Millisecond + maxRetries = 7 ) type gitCmdObjRunner struct { log *logrus.Entry innerRunner oscommands.ICmdObjRunner + // initialRetryDelay is the wait before the first lock-error retry. It's a + // field rather than the constant directly so tests can set it to zero and + // not actually sleep. + initialRetryDelay time.Duration } -// isRetryableError returns true if the error output indicates a transient -// lock-related error that may succeed on retry -func isRetryableError(output string) bool { - return strings.Contains(output, ".git/index.lock") || - strings.Contains(output, "cannot lock ref") +// isRetryableError returns true if a failed command hit a transient +// lock-related condition that may succeed on retry. The lock message can reach +// us either in the command's captured output or, for streamed commands whose +// output we don't capture, only in the returned error, so we check both. +// +// We match the bare "index.lock" fragment rather than a fuller path or message +// so we catch the lock wherever git puts it: the main .git dir, a linked +// worktree's git dir (.git/worktrees//index.lock), or a submodule's git +// dir. +func isRetryableError(output string, err error) bool { + text := output + if err != nil { + text += "\n" + err.Error() + } + return strings.Contains(text, "index.lock") || + strings.Contains(text, "cannot lock ref") } func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { @@ -33,41 +55,44 @@ func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { } func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) { - var output string - var err error - for range RetryCount { - newCmdObj := cmdObj.Clone() - output, err = self.innerRunner.RunWithOutput(newCmdObj) - - if err == nil || !isRetryableError(output) { - return output, err - } - - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) - } - - return output, err + return self.retryOnLockError(func() (string, error) { + return self.innerRunner.RunWithOutput(cmdObj.Clone()) + }) } func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) { var stdout, stderr string - var err error - for range RetryCount { - newCmdObj := cmdObj.Clone() - stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj) + _, err := self.retryOnLockError(func() (string, error) { + var runErr error + stdout, stderr, runErr = self.innerRunner.RunWithOutputs(cmdObj.Clone()) + return stdout + stderr, runErr + }) + return stdout, stderr, err +} - if err == nil || !isRetryableError(stdout+stderr) { - return stdout, stderr, err +// retryOnLockError runs the given function, retrying if it fails with a +// transient lock error (see isRetryableError). The string returned by run is +// the command output we inspect to classify the failure. We clone the command +// for each attempt (inside run) because an *exec.Cmd can only be run once. +func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) { + delay := self.initialRetryDelay + var output string + var err error + for attempt := range maxRetries { + output, err = run() + + if err == nil || !isRetryableError(output, err) { + break } - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) + if attempt < maxRetries-1 { + self.log.Warnf("lock error prevented command from running; retrying in %s", delay) + time.Sleep(delay) + delay *= 2 + } } - return stdout, stderr, err + return output, err } // Retry logic not implemented here, but these commands typically don't need to obtain a lock. diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go new file mode 100644 index 000000000..bf938da54 --- /dev/null +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -0,0 +1,137 @@ +package commands + +import ( + "errors" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +type runnerResult struct { + output string + err error +} + +// scriptedRunner is an ICmdObjRunner stub that returns a preconfigured result +// for each successive call, letting us drive the retry loop deterministically. +// It counts calls so tests can assert whether a command was retried. +type scriptedRunner struct { + results []runnerResult + calls int +} + +func (self *scriptedRunner) next() (string, error) { + result := self.results[self.calls] + self.calls++ + return result.output, result.err +} + +func (self *scriptedRunner) Run(*oscommands.CmdObj) error { + _, err := self.next() + return err +} + +func (self *scriptedRunner) RunWithOutput(*oscommands.CmdObj) (string, error) { + return self.next() +} + +func (self *scriptedRunner) RunWithOutputs(*oscommands.CmdObj) (string, string, error) { + output, err := self.next() + return output, "", err +} + +func (self *scriptedRunner) RunAndProcessLines(*oscommands.CmdObj, func(string) (bool, error)) error { + panic("not implemented") +} + +func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner { + return &gitCmdObjRunner{ + log: utils.NewDummyLog(), + innerRunner: inner, + // don't actually sleep between retries + initialRetryDelay: 0, + } +} + +// dummyCmdObj returns a throwaway command; only its clonability matters, since +// the scriptedRunner ignores it and returns preconfigured results. +func dummyCmdObj() *oscommands.CmdObj { + return oscommands.NewDummyCmdObjBuilder(nil).New([]string{"git", "status"}) +} + +func TestRunWithOutputReturnsSuccessWithoutRetrying(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "done", err: nil}}} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputDoesNotRetryNonLockError(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "boom", err: errors.New("boom")}}} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{ + {output: "fatal: Unable to create '/repo/.git/index.lock': File exists.", err: errors.New("exit status 128")}, + {output: "done", err: nil}, + }} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 2, inner.calls) +} + +func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { + // A streamed command (e.g. an amend run through the gpg helper) doesn't + // capture its output, so a lock failure surfaces only in the returned error + // with an empty output string. The retry logic must still recognize it. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) +} + +func TestRunWithOutputGivesUpAfterMaxRetries(t *testing.T) { + results := make([]runnerResult, maxRetries) + for i := range results { + results[i] = runnerResult{err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")} + } + inner := &scriptedRunner{results: results} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, maxRetries, inner.calls) +} + +func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) { + // In a linked worktree the lock lives at .git/worktrees//index.lock + // rather than .git/index.lock, so only matching the bare "index.lock" + // fragment lets the retry fire there too. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/worktrees/feature/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) +} diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 0a4a06477..72ade5110 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -7,6 +7,7 @@ import ( "sync" "unsafe" + "github.com/jesseduffield/lazygit/pkg/utils" "golang.org/x/sys/windows" ) @@ -15,15 +16,13 @@ type winPty struct { inWrite *os.File outRead *os.File - // mu guards the teardown state below and serializes it against Resize. - // hpcClosed gates ClosePseudoConsole (it must run exactly once) and also - // keeps Resize from touching the HPCON once it's been freed: the - // background waiter in StartPty closes the pseudoconsole on child exit, - // which would otherwise race a concurrent onResize and hand - // ResizePseudoConsole a freed handle. + // mu guards hpcClosed, which gates ClosePseudoConsole (it must run + // exactly once) and also keeps Resize from touching the HPCON once it's + // been freed: the background waiter in StartPty closes the pseudoconsole + // on child exit, which would otherwise race a concurrent onResize and + // hand ResizePseudoConsole a freed handle. mu sync.Mutex hpcClosed bool - closed bool } func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) } @@ -49,11 +48,6 @@ func (p *winPty) Resize(cols, rows uint16) error { func (p *winPty) closeHpc() { p.mu.Lock() defer p.mu.Unlock() - p.closeHpcLocked() -} - -// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu. -func (p *winPty) closeHpcLocked() { if p.hpcClosed { return } @@ -61,18 +55,31 @@ func (p *winPty) closeHpcLocked() { windows.ClosePseudoConsole(p.hpc) } +// Close tears the pty down without waiting for it: the teardown runs on a +// background goroutine and Close returns immediately. +// +// It has to, because ClosePseudoConsole can block for a long time: before +// Windows 11 24H2 it waits for the console host to exit, and since closing +// only delivers CTRL_CLOSE_EVENT to the attached client without terminating +// it, a client that keeps running (git still computing an expensive diff, a +// pager waiting for input) keeps the host — and with it ClosePseudoConsole — +// alive arbitrarily long. Close is called while holding the global PtyMutex +// and while the task's onDone once is executing, where blocking wedges every +// subsequent task for the view (and with it the UI), so none of this may +// happen on the caller's thread. +// +// Within the teardown, the pipe ends must be closed before the +// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes +// the client's pending output into the out pipe, and with the task stopped +// nobody is reading anymore, so that flush can only complete once the pipe +// is broken. The background waiter's closeHpc may already be wedged in such +// a flush while holding p.mu; closing the pipes is what unblocks it. func (p *winPty) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed { - return nil - } - p.closed = true - // Closing the pseudoconsole breaks the pipes; the child's next write - // fails and it exits. Then we close our ends of the pipes. - p.closeHpcLocked() - p.inWrite.Close() - p.outRead.Close() + go utils.Safe(func() { + p.inWrite.Close() + p.outRead.Close() + p.closeHpc() + }) return nil } diff --git a/pkg/gocui/flush_test.go b/pkg/gocui/flush_test.go index 59bae427c..d4082fcf6 100644 --- a/pkg/gocui/flush_test.go +++ b/pkg/gocui/flush_test.go @@ -39,15 +39,15 @@ func setupViews(t *testing.T, g *Gui) (*View, *View) { return status, main } -// pushContentOnly pushes a content-only event directly to the channel -// (synchronous, deterministic — unlike Update which spawns a goroutine). +// pushContentOnly enqueues a content-only event directly, letting the test +// control the contentOnly flag (which Update/UpdateContentOnly hard-code). func pushContentOnly(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: true}) } -// pushRegular pushes a regular event directly to the channel. +// pushRegular enqueues a regular (non-content-only) event directly. func pushRegular(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: false}) } func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) { diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 6002ebf9c..ceb570c59 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -133,7 +133,7 @@ type Gui struct { viewMouseBindings []*ViewMouseBinding lastClick *clickInfo gEvents chan GocuiEvent - userEvents chan userEvent + userEvents *userEventQueue views []*View currentView *View managers []Manager @@ -251,12 +251,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.stop = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) - // Update does a non-blocking send and panics on a full channel rather than - // blocking (which would deadlock the UI goroutine against itself) or - // silently reordering. The buffer is sized well above the peak occupancy we - // see in practice, so the panic stays unreachable in normal use; if it ever - // fires, that's a real anomaly to investigate, not a cue to grow the buffer. - g.userEvents = make(chan userEvent, 256) + g.userEvents = newUserEventQueue() g.taskManager = newTaskManager() if opts.PlayRecording { @@ -636,6 +631,13 @@ func (g *Gui) SetRenderSearchStatusFunc(renderSearchStatusFunc func(*View, int, g.renderSearchStatusFunc = renderSearchStatusFunc } +// SetUpdateQueueHighWaterMarkHandler registers a diagnostic callback invoked +// with the new depth whenever the queue of pending Update callbacks reaches a +// new maximum. It may be called from any goroutine. +func (g *Gui) SetUpdateQueueHighWaterMarkHandler(f func(depth int)) { + g.userEvents.setHighWaterMarkHandler(f) +} + // userEvent represents an event triggered by the user. type userEvent struct { f func(*Gui) error @@ -646,15 +648,99 @@ type userEvent struct { contentOnly bool } -// Update enqueues f on the user-events channel for the UI loop to run on its -// next iteration. Multiple Update calls from the same goroutine arrive in -// source order via the channel's FIFO. The send is non-blocking — if the -// channel is full we panic rather than block or silently reorder, since a -// blocked send from the UI goroutine would deadlock against itself and -// silently switching to inline execution would break the ordering guarantee -// callers rely on. The buffer is sized generously enough that this should -// never fire in practice; if it does, that's a signal to investigate, not -// to grow the buffer reflexively. +// userEventQueue is an unbounded, order-preserving FIFO of work enqueued by +// Update and friends for the main loop to run. +// +// It's unbounded (rather than a fixed-size channel) because producers must +// never block or lose work. Update can be called from the UI goroutine itself, +// where a blocking send would deadlock against the loop that drains the queue; +// and it can be called from arbitrary worker goroutines that may enqueue faster +// than the loop drains. That happens while the loop is stalled — suspended for +// a subprocess (the editor runs on the UI thread), or hung in a long handler — +// and also when a long-running worker operation emits a steady stream of +// updates that outpaces the loop (e.g. the waiting-status spinner ticks while a +// large directory is toggled into a custom patch). A fixed channel forces a +// choice between blocking (deadlock), dropping or reordering, and panicking on +// overflow; an unbounded queue avoids all three while preserving FIFO order. +// +// enqueue appends under the mutex and rings the doorbell; the main loop selects +// on the doorbell to wake, then drains the slice to empty. The doorbell is +// buffered(1) and rung with a non-blocking send, so it's a coalescing "work +// pending" flag rather than a per-event signal: a burst of appends leaves at +// most one token, and the loop drains everything the token represents on a +// single wake. A token left over after a drain (because the drain happened to +// empty the slice after the ring) just causes one harmless empty wake. +type userEventQueue struct { + mutex sync.Mutex + events []userEvent + doorbell chan struct{} + + // highWaterMark is the deepest the queue has ever been, and + // onHighWaterMark (if set) is called with the new depth each time that + // record is broken. Purely diagnostic: it lets us see how deep the queue + // gets in practice (see SetUpdateQueueHighWaterMarkHandler). + highWaterMark int + onHighWaterMark func(int) +} + +func newUserEventQueue() *userEventQueue { + return &userEventQueue{doorbell: make(chan struct{}, 1)} +} + +// enqueue appends an event and wakes the main loop. It never blocks. +func (q *userEventQueue) enqueue(ev userEvent) { + q.mutex.Lock() + q.events = append(q.events, ev) + newHighWaterMark := 0 + if len(q.events) > q.highWaterMark { + q.highWaterMark = len(q.events) + newHighWaterMark = q.highWaterMark + } + onHighWaterMark := q.onHighWaterMark + q.mutex.Unlock() + + // Report outside the lock: the handler does I/O (logging) and must not + // stall other producers or the draining loop. + if newHighWaterMark > 0 && onHighWaterMark != nil { + onHighWaterMark(newHighWaterMark) + } + + select { + case q.doorbell <- struct{}{}: + default: + } +} + +func (q *userEventQueue) setHighWaterMarkHandler(f func(int)) { + q.mutex.Lock() + q.onHighWaterMark = f + q.mutex.Unlock() +} + +// dequeue pops the oldest event, reporting false when the queue is empty. +func (q *userEventQueue) dequeue() (userEvent, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + + if len(q.events) == 0 { + return userEvent{}, false + } + ev := q.events[0] + if len(q.events) == 1 { + // Release the backing array whenever the queue drains, so a one-off + // burst doesn't pin its peak size for the rest of the session. + q.events = nil + } else { + q.events[0] = userEvent{} + q.events = q.events[1:] + } + return ev, true +} + +// Update enqueues f for the UI loop to run on its next iteration. Multiple +// Update calls from the same goroutine arrive in source order (the queue is +// FIFO). The enqueue never blocks and never drops work; see userEventQueue for +// why the queue is unbounded. func (g *Gui) Update(f func(*Gui) error) { g.update(f, false) } @@ -668,12 +754,7 @@ func (g *Gui) UpdateBackground(f func(*Gui) error) { func (g *Gui) update(f func(*Gui) error, background bool) { task := g.taskManager.NewTask(background) - - select { - case g.userEvents <- userEvent{f: f, task: task}: - default: - panic("gocui: userEvents channel full; refusing to block or reorder") - } + g.userEvents.enqueue(userEvent{f: f, task: task}) } // Like Update, but signals that the callback only modifies content. @@ -688,7 +769,7 @@ func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) { func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { task := g.taskManager.NewTask(background) - g.userEvents <- userEvent{f: f, task: task, contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true}) } // IsUIThread reports whether the caller is running on the main event-loop @@ -874,7 +955,14 @@ func (g *Gui) processEvent() error { if err := g.handleError(g.handleEvent(&ev)); err != nil { return err } - case ev := <-g.userEvents: + case <-g.userEvents.doorbell: + ev, ok := g.userEvents.dequeue() + if !ok { + // A leftover doorbell token whose events were already drained by a + // previous iteration's processRemainingEvents: nothing to run and + // nothing new to render. + return nil + } contentOnly = ev.contentOnly g.currentTask = ev.task defer func() { g.currentTask = nil; ev.task.Done() }() @@ -907,15 +995,20 @@ func (g *Gui) processRemainingEvents() (bool, error) { if err := g.handleError(g.handleEvent(&ev)); err != nil { return false, err } - case ev := <-g.userEvents: + default: + // No gui event is pending; drain a queued user event instead. + // gui events take priority so input stays responsive, but they're + // bounded (buffer of 20), so this can't starve the user-event queue. + ev, ok := g.userEvents.dequeue() + if !ok { + return contentOnly, nil + } contentOnly = ev.contentOnly && contentOnly err := g.handleError(ev.f(g)) ev.task.Done() if err != nil { return false, err } - default: - return contentOnly, nil } } } diff --git a/pkg/gocui/user_event_queue_test.go b/pkg/gocui/user_event_queue_test.go new file mode 100644 index 000000000..e547debb4 --- /dev/null +++ b/pkg/gocui/user_event_queue_test.go @@ -0,0 +1,111 @@ +package gocui + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Enqueuing far more events than the old fixed 256-slot buffer, without the +// main loop draining them, used to panic ("userEvents channel full"). It must +// not: producers can legitimately burst faster than a stalled UI loop drains +// (e.g. one command-log entry per git command when adding a large directory to +// a custom patch, or any producer while the loop is blocked in a subprocess). +// The events must also stay in FIFO order. +func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) { + g := newTestGui(t) + + const n = 1000 + var got []int + for i := range n { + g.Update(func(*Gui) error { + got = append(got, i) + return nil + }) + } + + // Drain the whole queue the way the main loop's inner drain does. + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + want := make([]int, n) + for i := range want { + want[i] = i + } + assert.Equal(t, want, got) +} + +// The high-water-mark handler fires only when the queue reaches a new maximum +// depth, reporting that depth. It does not reset when the queue drains. +func TestUpdateQueueHighWaterMark(t *testing.T) { + g := newTestGui(t) + + var marks []int + g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { marks = append(marks, depth) }) + + noop := func(*Gui) error { return nil } + + // Three enqueues with no drain: new highs 1, 2, 3. + g.Update(noop) + g.Update(noop) + g.Update(noop) + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + // Two enqueues stay below the previous high of 3: no new marks. + g.Update(noop) + g.Update(noop) + _, err = g.processRemainingEvents() + assert.NoError(t, err) + + // Four enqueues with no drain: only depth 4 beats the previous high. + for range 4 { + g.Update(noop) + } + + assert.Equal(t, []int{1, 2, 3, 4}, marks) +} + +// Concurrent producers must be able to enqueue safely (run under -race). Only +// same-goroutine order is guaranteed, so we check that every event is delivered +// exactly once and that each producer's own events stay in order. +func TestUpdateConcurrentProducers(t *testing.T) { + g := newTestGui(t) + + const producers = 8 + const perProducer = 500 + + type item struct{ producer, seq int } + var got []item + + var wg sync.WaitGroup + for p := range producers { + wg.Add(1) + go func() { + defer wg.Done() + for seq := range perProducer { + g.Update(func(*Gui) error { + got = append(got, item{p, seq}) + return nil + }) + } + }() + } + // Update is a synchronous, non-blocking enqueue, so once every producer has + // returned, every event is in the queue and a single drain sees them all. + wg.Wait() + + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + assert.Len(t, got, producers*perProducer) + lastSeq := make([]int, producers) + for p := range lastSeq { + lastSeq[p] = -1 + } + for _, it := range got { + assert.Equal(t, lastSeq[it.producer]+1, it.seq, "producer %d events out of order", it.producer) + lastSeq[it.producer] = it.seq + } +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 1e6e8f9c3..931b0909e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -421,6 +421,10 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context return nil }) + gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { + gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth) + }) + gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) { ctx, ok := gui.helpers.View.ContextForView(v.Name()) if ok {