From 1def541acb476be744f7a2a54e501300a9cf9493 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:44:23 +0200 Subject: [PATCH] Add IsUIThread and OnUIThreadAndWait to gocui The next commits move refresh workers to read UI-thread-owned state (the model, contexts, selection) on the UI thread rather than off it. Two primitives support that: - OnUIThreadAndWait runs a function on the main event loop and blocks the caller until it has run, so a worker can read that state without racing. OnUIThreadAndWaitBackground is the same for background routines, whose work must not count towards the program being busy. - IsUIThread reports whether the caller is on the main event loop, for a debug-only assertion that a refresh was issued from the thread it claims. It records the main loop's goroutine id in MainLoop and compares via goid, so it's promoted from an indirect to a direct dependency. goid is used only by that debug assertion, never to drive production control flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- pkg/gocui/gui.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6820d941d..c10004176 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 + github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.3 github.com/samber/lo v1.53.0 @@ -62,7 +63,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/onsi/ginkgo v1.10.3 // indirect github.com/onsi/gomega v1.34.1 // indirect - github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/mod v0.35.0 // indirect diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a13744997..6002ebf9c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -9,11 +9,13 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/gdamore/tcell/v3" "github.com/go-errors/errors" "github.com/jesseduffield/generics/set" + "github.com/petermattis/goid" "github.com/rivo/uniseg" "github.com/samber/lo" ) @@ -200,6 +202,11 @@ type Gui struct { currentTask Task lastHoverView *View + + // uiThreadID is the goroutine id of the main event loop, recorded when + // MainLoop starts. IsUIThread compares against it. Written once, read from + // worker goroutines, so it's atomic. + uiThreadID atomic.Int64 } type NewGuiOpts struct { @@ -684,6 +691,46 @@ func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { g.userEvents <- userEvent{f: f, task: task, contentOnly: true} } +// IsUIThread reports whether the caller is running on the main event-loop +// goroutine (the one running MainLoop). It calls goid.Get, so use it only for +// debug assertions, not to drive production control flow. +func (g *Gui) IsUIThread() bool { + return goid.Get() == g.uiThreadID.Load() +} + +// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the +// caller until f has run, returning f's error. Use it to read UI-thread-owned +// state (the model, contexts) from a worker without racing the UI thread. +// +// It must be called from a worker goroutine, never from the UI thread itself: +// the UI thread would block waiting for a callback only it can run, which +// deadlocks. Callers arrange this by construction (see the refresh helper's +// RefreshFromWorker); a debug-only assertion there guards against getting it +// wrong. +func (g *Gui) OnUIThreadAndWait(f func() error) error { + return g.onUIThreadAndWait(f, false) +} + +// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine, +// so it doesn't count towards the program being busy (see UpdateBackground). +func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error { + return g.onUIThreadAndWait(f, true) +} + +func (g *Gui) onUIThreadAndWait(f func() error, background bool) error { + enqueue := g.Update + if background { + enqueue = g.UpdateBackground + } + + result := make(chan error, 1) + enqueue(func(*Gui) error { + result <- f() + return nil + }) + return <-result +} + // Calls a function in a goroutine. Handles panics gracefully and tracks // number of background tasks. // Always use this when you want to spawn a goroutine and you want lazygit to @@ -766,6 +813,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + g.uiThreadID.Store(goid.Get()) + go func() { for { select {