fix(bonfire): kill a live view on a signal only after it has started (IN-12, quick-261028-rqd)

This commit is contained in:
Jakub Zych
2026-10-28 20:37:30 +01:00
parent 6efb9a4997
commit f91d70d05e
4 changed files with 147 additions and 20 deletions

View File

@@ -366,12 +366,13 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
// 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{})
ready := make(chan struct{})
liveMu.Lock()
if activeView != nil {
liveMu.Unlock()
return false, nil
}
prog := tea.NewProgram(model,
prog := tea.NewProgram(startedModel{Model: model, once: &sync.Once{}, ready: ready},
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
@@ -386,7 +387,7 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo
_, _ = prog.Run()
}()
stopWatch := watchContext(ctx, view, finished)
stopSignals := watchSignals(view, finished)
stopSignals := watchSignals(view, ready, finished)
completed := false
defer func() {
@@ -447,16 +448,37 @@ func watchContext(ctx context.Context, view *liveView, finished <-chan struct{})
return func() { <-exited }
}
// startedModel wraps a live view's model so the signal relay knows when
// the program has started: bubbletea v2.1.0 calls Init only after it has
// started the renderer, so once ready is closed a Kill restores the cursor
// and the terminal.
type startedModel struct {
tea.Model
once *sync.Once
ready chan struct{}
}
func (m startedModel) Init() tea.Cmd {
m.once.Do(func() { close(m.ready) })
return m.Model.Init()
}
func (m startedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
inner, cmd := m.Model.Update(msg)
m.Model = inner
return m, cmd
}
// 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
// signal the program is killed (bubbletea then restores the cursor and the
// terminal), the view is unregistered and the signal is raised again on
// the process with the relay stopped, so its default action, or the
// command's own handler, still applies. The returned function stops the
// relay when the view ends normally; a signal that arrived meanwhile is
// raised again too.
func watchSignals(view *liveView, finished <-chan struct{}) (stop func()) {
// signal the program is killed once it has started (ready is closed) or
// its Run has ended (bubbletea then restores the cursor and the terminal),
// the view is unregistered and the signal is raised again on the process
// with the relay stopped, so its default action, or the command's own
// handler, still applies. The returned function stops the relay when the
// view ends normally; a signal that arrived meanwhile is raised again too.
func watchSignals(view *liveView, ready, finished <-chan struct{}) (stop func()) {
var relay []os.Signal
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
if !signal.Ignored(sig) {
@@ -472,19 +494,14 @@ func watchSignals(view *liveView, finished <-chan struct{}) (stop func()) {
done := make(chan struct{})
go func() {
defer close(done)
select {
case sig := <-sigs:
view.prog.Kill()
<-finished
relaySignals(sigs, quit, ready, finished, view.prog.Kill, func() {
liveMu.Lock()
if activeView == view {
activeView = nil
}
liveMu.Unlock()
signal.Stop(sigs)
raise(sig)
case <-quit:
}
}, raise)
}()
return func() {
close(quit)
@@ -498,8 +515,35 @@ func watchSignals(view *liveView, finished <-chan struct{}) (stop func()) {
}
}
// relaySignals is the relay of watchSignals. On a signal from sigs it
// waits until the program has started (ready) or its Run has ended
// (finished), kills it, waits for finished, unregisters the view and the
// relay (unregister) and raises the signal. Killing before the program
// has started would leave the cursor hidden: bubbletea restores the
// terminal only when its renderer is running. quit ends the relay without
// a signal.
func relaySignals(sigs <-chan os.Signal, quit, ready, finished <-chan struct{}, kill, unregister func(), raise func(os.Signal)) {
select {
case sig := <-sigs:
select {
case <-ready:
case <-finished:
}
kill()
<-finished
unregister()
raise(sig)
case <-quit:
}
}
// raise sends sig to the own process; when that fails, it exits with 128
// plus the signal number, as a shell reports a death by that signal.
// plus the signal number, as a shell reports a death by that signal. That
// is the path on platforms where a process cannot signal itself (Windows):
// the relay exits after restoring the terminal, so a command's own signal
// handler may not run. Go cannot tell whether another handler is
// registered, and returning without exiting would swallow Ctrl+C for
// commands that have none.
func raise(sig os.Signal) {
p, err := os.FindProcess(os.Getpid())
if err == nil {