From 5bf3ed8b845e6d8ec03775671f52f009e6272884 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (chatgpt)" Date: Sun, 9 Aug 2026 14:30:55 -0400 Subject: [PATCH] Make terminal UI shutdown idempotent --- fix.txt | 2 +- uiterm/ui.go | 21 +++++++++++++-------- uiterm/ui_regression_test.go | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 uiterm/ui_regression_test.go diff --git a/fix.txt b/fix.txt index d6fadf1..bedf5e9 100644 --- a/fix.txt +++ b/fix.txt @@ -231,7 +231,7 @@ Priority 3: configuration, UI, and binding hardening never closes the FIFO descriptor. Exit the reader on terminal errors, close the descriptor, and make shutdown cancellable. -30. Empty UI tree can panic; UI startup errors are swallowed +[x] 30. Empty UI tree can panic; UI startup errors are swallowed Files: uiterm/tree.go, uiterm/ui.go Tree.uiKeyEvent indexes lines[activeLine] for a non-arrow key even when no lines exist. Guard empty trees. Ui.Run returns nil when termbox.Init diff --git a/uiterm/ui.go b/uiterm/ui.go index d34c74c..6870e01 100644 --- a/uiterm/ui.go +++ b/uiterm/ui.go @@ -3,6 +3,7 @@ package uiterm import ( "errors" "strings" + "sync" "sync/atomic" "github.com/nsf/termbox-go" @@ -20,8 +21,9 @@ type UiManager interface { type Ui struct { Fg, Bg Attribute - close chan bool - manager UiManager + close chan struct{} + closeOnce sync.Once + manager UiManager drawCount int32 elements map[string]*uiElement @@ -39,7 +41,7 @@ type uiElement struct { func New(manager UiManager) *Ui { ui := &Ui{ - close: make(chan bool, 10), + close: make(chan struct{}), elements: make(map[string]*uiElement), manager: manager, keyListeners: make(map[Key][]KeyListener), @@ -48,10 +50,9 @@ func New(manager UiManager) *Ui { return ui } +// Close is safe to call repeatedly and never blocks a caller. func (ui *Ui) Close() { - if termbox.IsInit { - ui.close <- true - } + ui.closeOnce.Do(func() { close(ui.close) }) } func (ui *Ui) Refresh() { @@ -97,7 +98,7 @@ func (ui *Ui) Run(cmds chan string) error { return nil } if err := termbox.Init(); err != nil { - return nil + return err } defer termbox.Close() termbox.SetInputMode(termbox.InputAlt) @@ -119,7 +120,11 @@ func (ui *Ui) Run(cmds chan string) error { select { case <-ui.close: return nil - case cmd := <-cmds: + case cmd, ok := <-cmds: + if !ok { + cmds = nil + continue + } ui.onCommandEvent(cmd) case event := <-events: switch event.Type { diff --git a/uiterm/ui_regression_test.go b/uiterm/ui_regression_test.go new file mode 100644 index 0000000..b334289 --- /dev/null +++ b/uiterm/ui_regression_test.go @@ -0,0 +1,16 @@ +package uiterm + +import "testing" + +// Regression: Close sent to a bounded channel and could block or enqueue +// duplicate shutdowns when called more than once. +func TestCloseIsNonblockingAndIdempotent(t *testing.T) { + ui := New(nil) + ui.Close() + ui.Close() + select { + case <-ui.close: + default: + t.Fatal("Close did not signal shutdown") + } +}