fix(dev): give serve the full shutdown grace before killing it

- dev.ShutdownGrace (internal, 15 seconds, longer than serve's 10-second
  drain) is the grace child.stop waits after SIGTERM, instead of 2 seconds
- summer's delegates use the same constant (appShutdownGrace)
- scaffolding docs say summer dev waits for serve's shutdown report (WR-03)
This commit is contained in:
Jakub Zych
2026-10-22 02:20:47 +02:00
parent 9c89705747
commit fc7706dfff
4 changed files with 56 additions and 6 deletions

View File

@@ -19,6 +19,13 @@ import (
const defaultDebounce = 200 * time.Millisecond
// ShutdownGrace is how long a stopped app binary may take to exit after
// SIGTERM before it is killed, for summer dev and summer's delegates alike.
// It is longer than the 10-second shutdown timeout of serve (its request
// drain) and the workers, so they finish a graceful shutdown and print
// their report.
const ShutdownGrace = 15 * time.Second
// Options customizes the watch loop. Tests inject Build and Start.
type Options struct {
Build func(ctx context.Context, appDir string, out io.Writer) error
@@ -224,6 +231,8 @@ func startChild(ctx context.Context, opts Options, binPath string, args []string
return &child{cmd: cmd, wait: ch}, nil
}
// stop sends the child SIGTERM and waits for it to exit, killing it only
// after ShutdownGrace.
func (c *child) stop() {
if c == nil || c.cmd == nil || c.cmd.Process == nil {
return
@@ -231,7 +240,7 @@ func (c *child) stop() {
_ = c.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-c.wait:
case <-time.After(2 * time.Second):
case <-time.After(ShutdownGrace):
_ = c.cmd.Process.Kill()
<-c.wait
}

View File

@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
@@ -620,3 +621,43 @@ func TestDevGracefulNil(t *testing.T) {
t.Fatalf("no dev report:\n%s", out.String())
}
}
// TestStopHelperProcess is not a real test: TestDevStopWaitsForGracefulShutdown
// re-executes the test binary with DEV_STOP_HELPER=1 to get a child that,
// like serve draining requests, takes 2.5 seconds to shut down after
// SIGTERM and then exits 0.
func TestStopHelperProcess(t *testing.T) {
if os.Getenv("DEV_STOP_HELPER") != "1" {
return
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM)
fmt.Println("ready")
select {
case <-sigs:
time.Sleep(2500 * time.Millisecond)
os.Exit(0)
case <-time.After(30 * time.Second):
os.Exit(3)
}
}
// TestDevStopWaitsForGracefulShutdown (WR-03): child.stop sends SIGTERM
// and waits for a child that takes longer than two seconds to drain
// instead of killing it; the grace exceeds serve's 10-second drain.
func TestDevStopWaitsForGracefulShutdown(t *testing.T) {
if ShutdownGrace <= 10*time.Second {
t.Fatalf("ShutdownGrace %s does not exceed serve's 10-second drain", ShutdownGrace)
}
t.Setenv("DEV_STOP_HELPER", "1")
var out safeBuffer
c, err := startChild(t.Context(), Options{Start: startBin}, os.Args[0], []string{"-test.run=^TestStopHelperProcess$"}, &out, &out)
if err != nil {
t.Fatal(err)
}
waitUntil(t, "helper ready", func() bool { return strings.Contains(out.String(), "ready") })
c.stop()
if st := c.cmd.ProcessState; st == nil || !st.Exited() || st.ExitCode() != 0 {
t.Fatalf("child was not allowed to finish its shutdown: %v", st)
}
}