Make terminal UI shutdown idempotent

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-09 14:30:55 -04:00
committed by Brandon McGinty
parent 41759b95f6
commit 5bf3ed8b84
3 changed files with 30 additions and 9 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ Priority 3: configuration, UI, and binding hardening
never closes the FIFO descriptor. Exit the reader on terminal errors, never closes the FIFO descriptor. Exit the reader on terminal errors,
close the descriptor, and make shutdown cancellable. 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 Files: uiterm/tree.go, uiterm/ui.go
Tree.uiKeyEvent indexes lines[activeLine] for a non-arrow key even when 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 no lines exist. Guard empty trees. Ui.Run returns nil when termbox.Init
+13 -8
View File
@@ -3,6 +3,7 @@ package uiterm
import ( import (
"errors" "errors"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"github.com/nsf/termbox-go" "github.com/nsf/termbox-go"
@@ -20,8 +21,9 @@ type UiManager interface {
type Ui struct { type Ui struct {
Fg, Bg Attribute Fg, Bg Attribute
close chan bool close chan struct{}
manager UiManager closeOnce sync.Once
manager UiManager
drawCount int32 drawCount int32
elements map[string]*uiElement elements map[string]*uiElement
@@ -39,7 +41,7 @@ type uiElement struct {
func New(manager UiManager) *Ui { func New(manager UiManager) *Ui {
ui := &Ui{ ui := &Ui{
close: make(chan bool, 10), close: make(chan struct{}),
elements: make(map[string]*uiElement), elements: make(map[string]*uiElement),
manager: manager, manager: manager,
keyListeners: make(map[Key][]KeyListener), keyListeners: make(map[Key][]KeyListener),
@@ -48,10 +50,9 @@ func New(manager UiManager) *Ui {
return ui return ui
} }
// Close is safe to call repeatedly and never blocks a caller.
func (ui *Ui) Close() { func (ui *Ui) Close() {
if termbox.IsInit { ui.closeOnce.Do(func() { close(ui.close) })
ui.close <- true
}
} }
func (ui *Ui) Refresh() { func (ui *Ui) Refresh() {
@@ -97,7 +98,7 @@ func (ui *Ui) Run(cmds chan string) error {
return nil return nil
} }
if err := termbox.Init(); err != nil { if err := termbox.Init(); err != nil {
return nil return err
} }
defer termbox.Close() defer termbox.Close()
termbox.SetInputMode(termbox.InputAlt) termbox.SetInputMode(termbox.InputAlt)
@@ -119,7 +120,11 @@ func (ui *Ui) Run(cmds chan string) error {
select { select {
case <-ui.close: case <-ui.close:
return nil return nil
case cmd := <-cmds: case cmd, ok := <-cmds:
if !ok {
cmds = nil
continue
}
ui.onCommandEvent(cmd) ui.onCommandEvent(cmd)
case event := <-events: case event := <-events:
switch event.Type { switch event.Type {
+16
View File
@@ -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")
}
}