fix(bonfire): end a live view when its context is cancelled and print later lines plainly (WR-09)

This commit is contained in:
Jakub Zych
2026-10-22 03:56:37 +02:00
parent 1ac75ad59a
commit fce232d7c3
5 changed files with 121 additions and 9 deletions

View File

@@ -325,6 +325,9 @@ func (m processModel) View() tea.View {
type liveView struct {
owner *console
prog *tea.Program
// done is closed when the program's Run has returned; printAbove never
// sends to a program whose Run has ended, whatever ended it.
done <-chan struct{}
}
// The running live view, for PrintAbove and the owning console's output.
@@ -348,6 +351,13 @@ var errLivePanic = errors.New("bonfire: live view work panicked")
// 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.
//
// The program does not take ctx: when ctx is cancelled, a watcher ends
// the view with its last frame and unregisters it in the same step under
// liveMu, without waiting for work, so lines printed after the
// cancellation print plainly below the view instead of reaching a stopped
// program. A program whose Run ended on its own is unregistered as well
// and is never sent lines.
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()
@@ -355,6 +365,7 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
// Claim the slot before building the program: a widget that loses it
// builds none, so it registers no child context on ctx and opens no
// TEA_TRACE file. NewProgram takes no bonfire lock and prints nothing.
finished := make(chan struct{})
liveMu.Lock()
if activeView != nil {
liveMu.Unlock()
@@ -364,18 +375,17 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
view := &liveView{owner: c, prog: prog}
view := &liveView{owner: c, prog: prog, done: finished}
activeView = view
liveMu.Unlock()
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
stopWatch := watchContext(ctx, view, finished)
stopSignals := watchSignals(view, finished)
completed := false
@@ -392,9 +402,12 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
if activeView == view {
activeView = nil
}
// When the context watcher already finished the program, its Run
// has returned: Send returns at once and finished is closed.
prog.Send(finishMsg{err: result})
<-finished
liveMu.Unlock()
stopWatch()
stopSignals()
}()
err = work(prog.Send)
@@ -402,6 +415,38 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
return true, err
}
// watchContext ends view when ctx is cancelled before the program
// finishes: under liveMu it unregisters the view, sends the finish message
// with ctx's error and waits for the program, the same single step as
// runLive's cleanup, so a printer either reaches the running program or
// waits and prints plainly after the last frame. When the program finishes
// first (normal end, a signal, Run failing), the view is only unregistered.
// The returned function waits for the watcher; call it after finished is
// closed and without holding liveMu.
func watchContext(ctx context.Context, view *liveView, finished <-chan struct{}) (wait func()) {
exited := make(chan struct{})
go func() {
defer close(exited)
select {
case <-ctx.Done():
liveMu.Lock()
if activeView == view {
activeView = nil
}
view.prog.Send(finishMsg{err: ctx.Err()})
<-finished
liveMu.Unlock()
case <-finished:
liveMu.Lock()
if activeView == view {
activeView = nil
}
liveMu.Unlock()
}
}()
return func() { <-exited }
}
// watchSignals relays SIGINT and SIGTERM (those not ignored) while view
// runs. The views run without bubbletea's own signal handling, so without
// this a Ctrl+C would end the process with the cursor still hidden. On a
@@ -477,15 +522,25 @@ func printAboveLines(s string) bool {
}
// 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.
// the console that started the view, and reports whether it did. A view
// whose program has ended (its context was cancelled, or Run returned on
// its own) is not running: printAbove unregisters it if it is still
// registered and reports false, so the caller prints s plainly.
func printAbove(owner *console, s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeView == nil || (owner != nil && activeView.owner != owner) {
v := activeView
if v == nil || (owner != nil && v.owner != owner) {
return false
}
select {
case <-v.done:
activeView = nil
return false
default:
}
// One message for the whole block, sent while holding liveMu: blocks
// from different goroutines keep the order of their calls.
activeView.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
v.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
return true
}