diff --git a/ui.go b/ui.go index 71cfccc..702321f 100644 --- a/ui.go +++ b/ui.go @@ -63,9 +63,14 @@ func (b *Barnard) GetInputStatus() string { func (b *Barnard) UpdateInputStatus(status string) { status = truncateInputStatus(status) - b.UiInputStatus.Text = status - b.RebuildUserChannelTreePreservingSelection() - b.Ui.Refresh() + if b.Ui == nil { + return + } + b.Ui.Post(func() { + b.UiInputStatus.Text = status + b.RebuildUserChannelTreePreservingSelection() + b.Ui.Refresh() + }) } // truncateInputStatus limits terminal cells without splitting UTF-8 runes. @@ -79,7 +84,11 @@ func truncateInputStatus(status string) string { func (b *Barnard) AddOutputLine(line string) { now := time.Now() - b.UiOutput.AddLine(fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second())) + formatted := fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second()) + if b.Ui == nil { + return + } + b.Ui.Post(func() { b.UiOutput.AddLine(formatted) }) } func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) { diff --git a/uiterm/ui.go b/uiterm/ui.go index 5a19208..7b3d725 100644 --- a/uiterm/ui.go +++ b/uiterm/ui.go @@ -23,6 +23,7 @@ type Ui struct { close chan struct{} closeOnce sync.Once + events chan func() manager UiManager drawCount int32 @@ -42,6 +43,7 @@ type uiElement struct { func New(manager UiManager) *Ui { ui := &Ui{ close: make(chan struct{}), + events: make(chan func(), 256), elements: make(map[string]*uiElement), manager: manager, keyListeners: make(map[Key][]KeyListener), @@ -55,6 +57,22 @@ func (ui *Ui) Close() { ui.closeOnce.Do(func() { close(ui.close) }) } +// Post schedules UI work on Run's owning goroutine. It is deliberately +// bounded: network callbacks must not block behind slow terminal rendering. +func (ui *Ui) Post(fn func()) bool { + if fn == nil { + return true + } + select { + case <-ui.close: + return false + case ui.events <- fn: + return true + default: + return false + } +} + func (ui *Ui) Refresh() { if termbox.IsInit { ui.beginDraw() @@ -137,6 +155,8 @@ func (ui *Ui) Run(cmds chan string) error { select { case <-ui.close: return nil + case fn := <-ui.events: + fn() case cmd, ok := <-cmds: if !ok { cmds = nil diff --git a/uiterm/ui_regression_test.go b/uiterm/ui_regression_test.go index b334289..7f5dee8 100644 --- a/uiterm/ui_regression_test.go +++ b/uiterm/ui_regression_test.go @@ -4,6 +4,24 @@ import "testing" // Regression: Close sent to a bounded channel and could block or enqueue // duplicate shutdowns when called more than once. +// Regression: network callbacks modified terminal state directly. Post gives +// them a bounded handoff to the UI-owning Run goroutine instead of blocking. +func TestPostIsBoundedAndRejectsClosedUI(t *testing.T) { + ui := New(nil) + for i := 0; i < cap(ui.events); i++ { + if !ui.Post(func() {}) { + t.Fatal("queue filled too early") + } + } + if ui.Post(func() {}) { + t.Fatal("Post accepted work past queue capacity") + } + ui.Close() + if ui.Post(func() {}) { + t.Fatal("Post accepted work after close") + } +} + func TestCloseIsNonblockingAndIdempotent(t *testing.T) { ui := New(nil) ui.Close()