diff --git a/cmd/summer/runtime.go b/cmd/summer/runtime.go index e1a6c1b..83f572c 100644 --- a/cmd/summer/runtime.go +++ b/cmd/summer/runtime.go @@ -2,11 +2,16 @@ package main import ( "context" + "errors" "fmt" + "io" "os" "os/exec" + "os/signal" "path/filepath" "strings" + "syscall" + "time" "git.golem15.com/golem15/summercms/internal/build" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -95,16 +100,70 @@ func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error return fmt.Errorf("summer: refusing to recurse into %s", bin) } } + cmd := newAppCmd(ctx, bin, appDir, argv, out) + sigs := make(chan os.Signal, 2) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigs) + if err := runChild(cmd, sigs); err != nil { + return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err) + } + return nil +} + +// appShutdownGrace is how long a cancelled app binary may take to exit after +// SIGTERM before it is killed. It is longer than the 10-second shutdown +// timeout of serve and the workers, so they finish a graceful shutdown. +const appShutdownGrace = 15 * time.Second + +// newAppCmd builds the command that runs the app binary bin with argv in dir. +// The child inherits summer's stdin, so the hidden password prompts of +// admin:create and admin:reset-password read from the terminal. Cancelling +// ctx sends SIGTERM, and the child is killed only after appShutdownGrace. +func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd { cmd := exec.CommandContext(ctx, bin, argv...) - cmd.Dir = appDir + cmd.Dir = dir cmd.Stdout = out cmd.Stderr = out cmd.Stdin = os.Stdin cmd.Env = os.Environ() - if err := cmd.Run(); err != nil { - return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err) + cmd.Cancel = func() error { + err := cmd.Process.Signal(syscall.SIGTERM) + if err != nil && !errors.Is(err, os.ErrProcessDone) { + // SIGTERM is not supported everywhere (Windows); kill instead. + return cmd.Process.Kill() + } + return err + } + cmd.WaitDelay = appShutdownGrace + return cmd +} + +// runChild starts cmd, forwards every signal received on sigs to it and +// waits for it to exit, returning Wait's error. It never escalates to a kill: +// the app binary decides how to shut down. +// +// Ctrl+C in a terminal delivers SIGINT to both summer and the child, because +// they share the foreground process group, so the child may see SIGINT twice. +// The app's NotifyContext-based shutdown tolerates that. The child is +// deliberately not put in its own process group (Setpgid): a background +// process group that reads the terminal gets SIGTTIN, which would break the +// password prompts of admin:create and admin:reset-password. +func runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error { + if err := cmd.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + for { + select { + case err := <-done: + return err + case sig := <-sigs: + // Best effort: the child may already have exited + // (os.ErrProcessDone) or the platform may not support sig. + _ = cmd.Process.Signal(sig) + } } - return nil } func sameFile(a, b string) (bool, error) { diff --git a/cmd/summer/runtime_test.go b/cmd/summer/runtime_test.go index c3be3b4..838347b 100644 --- a/cmd/summer/runtime_test.go +++ b/cmd/summer/runtime_test.go @@ -1,10 +1,20 @@ package main import ( + "bytes" "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "runtime" "slices" "strings" + "sync" + "syscall" "testing" + "time" "git.golem15.com/golem15/summercms/modules/bonfire" ) @@ -113,3 +123,115 @@ func TestDelegateCommandsHelp(t *testing.T) { } } } + +// TestSignalHelperProcess is not a real test: the signal tests re-execute the +// test binary with SUMMER_SIGNAL_HELPER=1 to get a child that shuts down +// gracefully on SIGINT or SIGTERM, like the app binary's serve. +func TestSignalHelperProcess(t *testing.T) { + if os.Getenv("SUMMER_SIGNAL_HELPER") != "1" { + return + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + fmt.Println("ready") + select { + case <-ctx.Done(): + fmt.Println("graceful shutdown") + os.Exit(0) + case <-time.After(20 * time.Second): + os.Exit(3) + } +} + +// syncBuffer is a bytes.Buffer safe for the child's writes and the test's +// reads at the same time. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func helperArgv() []string { + return []string{"-test.run=^TestSignalHelperProcess$"} +} + +func helperEnv(cmd *exec.Cmd) { + cmd.Env = append(os.Environ(), "SUMMER_SIGNAL_HELPER=1") +} + +func waitForOutput(t *testing.T, out *syncBuffer, want string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(out.String(), want) { + if time.Now().After(deadline) { + t.Fatalf("child never printed %q; output:\n%s", want, out.String()) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestRunChildForwardsSignals(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("signal forwarding is POSIX-only") + } + for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(sig.String(), func(t *testing.T) { + var out syncBuffer + cmd := newAppCmd(context.Background(), os.Args[0], ".", helperArgv(), &out) + helperEnv(cmd) + sigs := make(chan os.Signal, 1) + done := make(chan error, 1) + go func() { done <- runChild(cmd, sigs) }() + waitForOutput(t, &out, "ready") + sigs <- sig + select { + case err := <-done: + if err != nil { + t.Fatalf("runChild: %v\n%s", err, out.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("runChild did not return after the signal") + } + if !strings.Contains(out.String(), "graceful shutdown") { + t.Fatalf("child did not shut down gracefully:\n%s", out.String()) + } + }) + } +} + +func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("SIGTERM is POSIX-only") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var out syncBuffer + cmd := newAppCmd(ctx, os.Args[0], ".", helperArgv(), &out) + helperEnv(cmd) + done := make(chan error, 1) + go func() { done <- runChild(cmd, nil) }() + waitForOutput(t, &out, "ready") + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled\n%s", err, out.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("child did not exit after cancel") + } + if !strings.Contains(out.String(), "graceful shutdown") { + t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String()) + } +} diff --git a/docs/console/introduction.md b/docs/console/introduction.md index cdbb9b6..472be1d 100644 --- a/docs/console/introduction.md +++ b/docs/console/introduction.md @@ -57,6 +57,8 @@ summer admin:create --email admin@example.com --superuser One leading `--` is ignored, so `summer serve -- --addr 127.0.0.1:8123` behaves the same as `summer serve --addr 127.0.0.1:8123`. +When `summer` receives SIGINT or SIGTERM while the binary runs, it passes the signal on and waits for the binary to shut down, so stopping `summer serve` with Ctrl+C or `kill` frees its port. + `summer` does not rebuild an existing binary before it delegates. Run `summer build` after you change code, or keep `summer dev` running. Every other runtime command, such as `deferred:purge` or the commands your plugins add, is run on the binary directly. ## The sections of this chapter