fix(bonfire): reroute only the owning console's lines to a live view

- the registered view keeps the console that started it; Println, Printf
  and status lines go above it only from that console, other consoles
  write to their own writers (bonfire.PrintAbove still reaches any view)
- runLive claims the single live slot by check and set under liveMu and
  reports when it could not, so the widget prints plain lines
- unregister, finish and wait run deferred, so a panic in work leaves no
  registration behind and still propagates (WR-02)
This commit is contained in:
Jakub Zych
2026-10-22 02:10:48 +02:00
parent b077dc3598
commit 86e4e270d3
6 changed files with 200 additions and 56 deletions

View File

@@ -8,6 +8,7 @@ package bonfire
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -314,26 +315,33 @@ func (m processModel) View() tea.View {
return tea.NewView(b.String())
}
// The running live program, for PrintAbove. Only one live view runs at a
// time; widgets started while one runs print plain lines.
var (
liveMu sync.Mutex
activeProg *tea.Program
)
// liveActive reports whether a live view is running.
func liveActive() bool {
liveMu.Lock()
defer liveMu.Unlock()
return activeProg != nil
// liveView is the running live program and the console that started it.
type liveView struct {
owner *console
prog *tea.Program
}
// The running live view, for PrintAbove and the owning console's output.
// Only one live view runs at a time; a widget that cannot claim the slot
// (a nested widget, or a second console at the same moment) prints plain
// lines.
var (
liveMu sync.Mutex
activeView *liveView
)
// errLivePanic is the result the final frame shows when work panicked.
var errLivePanic = errors.New("bonfire: live view work panicked")
// runLive runs model as a bubbletea program on out while work runs on the
// calling goroutine. work gets a send function for progress messages. When
// work returns, the program is unregistered (so PrintAbove stops sending to
// it), told to finish and waited for, all under liveMu; it never receives
// lines after that.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
// calling goroutine, with c as the owner of the view. It first claims the
// single live slot under liveMu; when another view holds it, runLive
// returns started false without running work, and the caller prints plain
// lines instead. work gets a send function for progress messages. When
// work returns or panics, the view is unregistered (so PrintAbove stops
// sending to it), told to finish and waited for, all under liveMu; it
// never receives lines after that, and a panic goes on after the cleanup.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) (started bool, err error) {
if ctx == nil {
ctx = context.Background()
}
@@ -344,38 +352,61 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
view := &liveView{owner: c, prog: prog}
liveMu.Lock()
if activeView != nil {
liveMu.Unlock()
return false, nil
}
activeView = view
liveMu.Unlock()
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
liveMu.Lock()
activeProg = prog
liveMu.Unlock()
err := work(prog.Send)
// Unregister, finish and wait as one step: a printer that arrives
// meanwhile waits for the lock and then writes after the final frame,
// so no line reaches a stopped program or races the last frame.
liveMu.Lock()
activeProg = nil
prog.Send(finishMsg{err: err})
<-finished
liveMu.Unlock()
return err
completed := false
defer func() {
result := err
if !completed {
result = errLivePanic
}
// Unregister, finish and wait as one step: a printer that arrives
// meanwhile waits for the lock and then writes after the final
// frame, so no line reaches a stopped program or races the last
// frame.
liveMu.Lock()
if activeView == view {
activeView = nil
}
prog.Send(finishMsg{err: result})
<-finished
liveMu.Unlock()
}()
err = work(prog.Send)
completed = true
return true, err
}
// printAboveLines prints s, one or more lines, above the running live view.
// It reports false when no view runs.
// printAboveLines prints s, one or more lines, above the running live view,
// whichever console started it (bonfire.PrintAbove). It reports false when
// no view runs.
func printAboveLines(s string) bool {
return printAbove(nil, s)
}
// printAbove prints s above the running live view when owner is nil or is
// the console that started the view, and reports whether it did.
func printAbove(owner *console, s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeProg == nil {
if activeView == nil || (owner != nil && activeView.owner != owner) {
return false
}
// One message for the whole block, sent while holding liveMu: blocks
// from different goroutines keep the order of their calls.
activeProg.Send(printLine(strings.TrimSuffix(s, "\n")))
activeView.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
return true
}