diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index ee43685b..988f7de3 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -27,7 +27,7 @@ Use cases: - Usage strings and argument-count checks derived from the declared arguments (`` for required, `[name]` for optional). - Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`). - Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream). -- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command. Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. +- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. - Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal. - Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins: diff --git a/modules/bonfire/live.go b/modules/bonfire/live.go index 9eec5684..672f624f 100644 --- a/modules/bonfire/live.go +++ b/modules/bonfire/live.go @@ -4,15 +4,20 @@ package bonfire // process.go and progress.go) to bubbletea v2, without SSU's git and // engine types. Each runs as a bubbletea program with input off (stdin // stays in cooked mode, so Ctrl+C reaches the command's own signal -// handling), no signal handler of its own and the command's context. +// handling), without bubbletea's signal handler and with the command's +// context. While a view runs, runLive relays SIGINT and SIGTERM only to +// restore the terminal before the signal takes its usual effect. import ( "context" "errors" "fmt" "io" + "os" + "os/signal" "strings" "sync" + "syscall" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" @@ -366,6 +371,7 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo defer close(finished) _, _ = prog.Run() }() + stopSignals := watchSignals(view, finished) completed := false defer func() { @@ -384,12 +390,80 @@ func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, wo prog.Send(finishMsg{err: result}) <-finished liveMu.Unlock() + stopSignals() }() err = work(prog.Send) completed = true return true, err } +// 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()) { + var relay []os.Signal + for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + if !signal.Ignored(sig) { + relay = append(relay, sig) + } + } + if len(relay) == 0 { + return func() {} + } + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, relay...) + quit := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + select { + case sig := <-sigs: + view.prog.Kill() + <-finished + liveMu.Lock() + if activeView == view { + activeView = nil + } + liveMu.Unlock() + signal.Stop(sigs) + raise(sig) + case <-quit: + } + }() + return func() { + close(quit) + <-done + signal.Stop(sigs) + select { + case sig := <-sigs: + raise(sig) + default: + } + } +} + +// 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. +func raise(sig os.Signal) { + p, err := os.FindProcess(os.Getpid()) + if err == nil { + err = p.Signal(sig) + } + if err != nil { + code := 1 + if s, ok := sig.(syscall.Signal); ok { + code = 128 + int(s) + } + os.Exit(code) + } +} + // 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. diff --git a/modules/bonfire/live_signal_unix_test.go b/modules/bonfire/live_signal_unix_test.go new file mode 100644 index 00000000..2d1ba422 --- /dev/null +++ b/modules/bonfire/live_signal_unix_test.go @@ -0,0 +1,108 @@ +//go:build unix + +package bonfire + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "testing" + "time" +) + +const ( + hideCursor = "\x1b[?25l" + showCursor = "\x1b[?25h" +) + +// TestLiveSignalHelper is the helper process of +// TestLiveViewRestoresTerminalOnSignal: with BONFIRE_LIVE_SIGNAL_HELPER set +// it runs a spinner on a terminal console over its stdout, prints ready on +// stderr and waits. In own_handler mode it also holds its own signal +// context and exits 0 once that context ends. +func TestLiveSignalHelper(t *testing.T) { + mode := os.Getenv("BONFIRE_LIVE_SIGNAL_HELPER") + if mode == "" { + return + } + ctx := context.Background() + if mode == "own_handler" { + var stop context.CancelFunc + ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + } + c := newConsole(strings.NewReader(""), os.Stdout, os.Stderr, noEnv, boolPtr(true), boolPtr(true)) + _ = c.Spinner("waiting for a signal", func() error { + fmt.Fprintln(os.Stderr, "ready") + select { + case <-ctx.Done(): + case <-time.After(20 * time.Second): + } + return nil + }) + if mode == "own_handler" && ctx.Err() != nil { + os.Exit(0) + } + os.Exit(3) +} + +// TestLiveViewRestoresTerminalOnSignal (WR-01): SIGINT during a live view +// shows the cursor again and then still ends the process by the signal, +// or reaches the command's own signal handler when it has one. +func TestLiveViewRestoresTerminalOnSignal(t *testing.T) { + for _, mode := range []string{"default", "own_handler"} { + t.Run(mode, func(t *testing.T) { + var stdout, stderr lockedBuffer + cmd := exec.Command(os.Args[0], "-test.run=^TestLiveSignalHelper$") + cmd.Env = append(os.Environ(), "BONFIRE_LIVE_SIGNAL_HELPER="+mode) + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for !strings.Contains(stderr.String(), "ready") || !strings.Contains(stdout.String(), hideCursor) { + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + t.Fatalf("helper not ready; stdout %q, stderr %q", stdout.String(), stderr.String()) + } + time.Sleep(10 * time.Millisecond) + } + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + t.Fatalf("helper did not end after SIGINT; stdout %q", stdout.String()) + } + out := stdout.String() + if i := strings.LastIndex(out, hideCursor); !strings.Contains(out[i:], showCursor) { + t.Errorf("cursor left hidden: no show-cursor after the last hide-cursor in %q", out) + } + switch mode { + case "default": + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("helper exited cleanly (%v), want death by SIGINT", err) + } + ws, ok := ee.Sys().(syscall.WaitStatus) + if !ok || !ws.Signaled() || ws.Signal() != syscall.SIGINT { + t.Errorf("helper ended with %v, want death by SIGINT", err) + } + case "own_handler": + if err != nil { + t.Errorf("helper with its own handler ended with %v, want exit 0; stderr %q", err, stderr.String()) + } + } + }) + } +}