Files
summercms/modules/bonfire/live_signal_unix_test.go
Jakub Zych d151a252c5 fix(bonfire): restore the terminal when a live view is interrupted
- while a view runs, runLive relays SIGINT and SIGTERM (unless ignored);
  on a signal it kills the program, which shows the cursor and restores
  the terminal, unregisters the view, stops relaying and raises the same
  signal again, so the default action or the command's own handler still
  applies
- the generated main, bonfire.Execute's context and Ctrl+C for commands
  without a live view are unchanged
- TestLiveViewRestoresTerminalOnSignal pins both paths in a re-executed
  helper process (WR-01)
2026-10-22 02:14:10 +02:00

109 lines
3.2 KiB
Go

//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())
}
}
})
}
}