- 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
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
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"
|
|
)
|
|
|
|
// captureAppRunner replaces appRunner for the test and returns a pointer to
|
|
// the argv of the last call.
|
|
func captureAppRunner(t *testing.T) *[]string {
|
|
t.Helper()
|
|
got := new([]string)
|
|
prev := appRunner
|
|
appRunner = func(ctx context.Context, argv []string, out bonfire.Output) error {
|
|
*got = append([]string(nil), argv...)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { appRunner = prev })
|
|
return got
|
|
}
|
|
|
|
func TestDelegatedServePassesFlags(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
args []string
|
|
want []string
|
|
}{
|
|
{[]string{"serve", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
|
|
{[]string{"serve", "--help"}, []string{"serve", "--help"}},
|
|
{[]string{"serve", "-h"}, []string{"serve", "-h"}},
|
|
{[]string{"serve", "--addr=:8123"}, []string{"serve", "--addr=:8123"}},
|
|
{[]string{"serve", "--", "--addr", ":8123"}, []string{"serve", "--addr", ":8123"}},
|
|
} {
|
|
got := captureAppRunner(t)
|
|
if out, err := runSummer(t, tc.args...); err != nil {
|
|
t.Fatalf("%q: %v\n%s", tc.args, err, out)
|
|
}
|
|
if !slices.Equal(*got, tc.want) {
|
|
t.Fatalf("%q: argv = %q, want %q", tc.args, *got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelegateCommandsPassArgs(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"migrate:rollback", "--plugin", "acme.blog"},
|
|
{"queue:work", "--queue", "a", "--queue", "b"},
|
|
{"schedule:run", "--once"},
|
|
{"queue:clear", "emails"},
|
|
{"migrate"},
|
|
{"migrate:status", "--help"},
|
|
{"route:list"},
|
|
{"key:generate"},
|
|
{"admin:create", "--email", "you@example.com", "--superuser"},
|
|
{"admin:reset-password", "admin@example.com"},
|
|
} {
|
|
got := captureAppRunner(t)
|
|
if out, err := runSummer(t, args...); err != nil {
|
|
t.Fatalf("%q: %v\n%s", args, err, out)
|
|
}
|
|
if !slices.Equal(*got, args) {
|
|
t.Fatalf("%q: argv = %q, want %q", args, *got, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelegateCommandsHelp(t *testing.T) {
|
|
hints := map[string][]string{
|
|
"migrate:rollback": {"--plugin"},
|
|
"queue:work": {"--queue"},
|
|
"schedule:run": {"--once"},
|
|
"serve": {"--addr"},
|
|
"admin:create": {"--email", "--superuser"},
|
|
}
|
|
cmds := delegateCommands()
|
|
if len(cmds) != 11 {
|
|
t.Fatalf("delegateCommands() has %d commands, want 11", len(cmds))
|
|
}
|
|
got := captureAppRunner(t)
|
|
for _, c := range cmds {
|
|
out, err := runSummer(t, "help", c.Name)
|
|
if err != nil {
|
|
t.Fatalf("help %s: %v", c.Name, err)
|
|
}
|
|
for _, want := range append([]string{"passed to the app binary"}, hints[c.Name]...) {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("help %s missing %q:\n%s", c.Name, want, out)
|
|
}
|
|
}
|
|
}
|
|
if *got != nil {
|
|
t.Fatalf("help ran the app binary with %q", *got)
|
|
}
|
|
|
|
out, err := runSummer(t, "--help")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range cmds {
|
|
found := false
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 0 && fields[0] == c.Name && strings.Contains(line, "(flags pass to the app binary)") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("summer --help does not mark %s:\n%s", c.Name, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|