fix(summer): forward SIGINT and SIGTERM to the app binary
- runAppBinary registers SIGINT and SIGTERM after its build and recursion checks and runChild forwards each signal to the app binary, then waits for it to exit, so stopping summer serve frees the port (WR-08) - newAppCmd keeps the inherited stdin, env and output, and a context cancel sends SIGTERM with a 15-second appShutdownGrace before SIGKILL - the child stays in summer's process group so the admin password prompts can read the terminal - helper-process tests cover SIGINT, SIGTERM and context cancel - console introduction notes the signal forwarding
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user