From f2215e132e0407d5c6a1c50f55d0ca92cd7a0b02 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 5 Sep 2026 15:28:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix=F0=9F=90=9B:=20handle=20SIGTERM,=20and?= =?UTF-8?q?=20stop=20exiting=20on=20a=20failed=20Shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects on one path, none of which could be seen from the code alone. **SIGTERM was never registered.** signal.Notify listened for os.Interrupt only, and Go terminates the process outright for a signal nothing handles. `docker stop`, a Kubernetes pod deletion and `systemctl stop` all send SIGTERM, so every line of the graceful shutdown below the wait was dead code outside a terminal: measured on a real binary, SIGINT printed "Shutdown Server ..." and "Server exiting" and SIGTERM printed neither. **A stuck shutdown could not be interrupted.** quit is buffered and signal.Notify stays armed after the first delivery, so further signals only refill the buffer. That was harmless while SIGTERM went to the default handler - it was the escape hatch. Registering it removes the hatch, so the disposition is now restored once the first signal is taken, and a second one kills the process the default way. Arming is split from waiting so a caller can arm before it announces readiness; a signal in between reaches the default handler, which is the very failure being fixed. **A failed Shutdown skipped everything after it.** log.Fatal is an unconditional os.Exit(1), and Shutdown reports an error precisely when connections were still in flight - the moment the cleanup that follows matters most. It is an error now, and the process carries on. That failure is closer than it looks. net/http only treats a StateNew connection as idle once it is over five seconds old, so a connection opened shortly before the signal that has sent nothing holds the whole budget: with the shipped settings.yml (readtimeout 1) the server closes it first and shutdown takes 5ms, but with settings.demo.yml (readtimeout 10000) the same connection made shutdown take 5.04s and exit 1, printing no "Server exiting". The default configuration is what has been hiding this. The wait and the shutdown are extracted so the subprocess tests can drive the real functions against an empty http.Server: CI has no database, and none of this needs one. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- cmd/api/server.go | 68 +++++++++-- cmd/api/signal_test.go | 266 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 cmd/api/signal_test.go diff --git a/cmd/api/server.go b/cmd/api/server.go index 83697e6b..de35e0ac 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "syscall" "time" "github.com/gin-gonic/gin" @@ -85,8 +86,8 @@ func run() error { runStartupHooks() srv := &http.Server{ - Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port), - Handler: sdk.Runtime.GetEngine(), + Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port), + Handler: sdk.Runtime.GetEngine(), ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second, WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second, } @@ -135,23 +136,68 @@ func run() error { fmt.Printf("- Local: http://localhost:%d/swagger/admin/index.html \r\n", config.ApplicationConfig.Port) fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port) fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr()) - // 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间) - quit := make(chan os.Signal, 1) - signal.Notify(quit, os.Interrupt) - <-quit - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + waitForStopSignal() + log.Info("Shutdown Server ... ") - - if err := srv.Shutdown(ctx); err != nil { - log.Fatal("Server Shutdown:", err) + if err := shutdownServer(srv, shutdownTimeout); err != nil { + // Not log.Fatal: that is an unconditional os.Exit(1), and Shutdown + // reports an error exactly when connections were still in flight - + // which is when the cleanup that follows matters most. + log.Error("Server Shutdown: ", err) } log.Info("Server exiting") return nil } +// shutdownTimeout is how long Shutdown waits for in-flight requests. It plus +// whatever cleanup follows has to stay inside the orchestrator's grace period +// - `docker stop` allows 10s by default before it sends SIGKILL. +const shutdownTimeout = 5 * time.Second + +// waitForStopSignal blocks until the process is asked to stop. +// +// SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes +// pod deletion and `systemctl stop` all send it, and Go terminates the process +// immediately for a signal nobody listens for. Registering only os.Interrupt +// meant every graceful shutdown below this line was dead code outside a +// terminal. +// +// The disposition is restored before returning, so a second signal kills the +// process the default way. The channel keeps the notification we already took, +// so without this a stuck shutdown would swallow every further signal - once +// SIGTERM is registered there is no escape hatch left but SIGKILL. +func waitForStopSignal() os.Signal { + quit, disarm := armStopSignals() + defer disarm() + return <-quit +} + +// armStopSignals registers for the stop signals and returns the channel they +// arrive on together with the function that restores the default disposition. +// +// Registering is separate from waiting so a caller can arm before it announces +// that it is ready: a signal that arrives between the two is delivered to the +// default handler, which for both of these means the process dies without +// running any of this. +func armStopSignals() (<-chan os.Signal, func()) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + return quit, func() { signal.Stop(quit) } +} + +// shutdownServer stops srv, giving in-flight requests up to timeout to finish. +// +// It returns the error instead of exiting on it. A caller that exits here skips +// its own cleanup, and Shutdown fails precisely when there was something left +// to clean up after. +func shutdownServer(srv *http.Server, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return srv.Shutdown(ctx) +} + // runStartupHooks runs the router registries and then the before callbacks. // // The package-level slice runs first and in its existing order, so a fork that diff --git a/cmd/api/signal_test.go b/cmd/api/signal_test.go new file mode 100644 index 00000000..604b27c3 --- /dev/null +++ b/cmd/api/signal_test.go @@ -0,0 +1,266 @@ +package api + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "strings" + "syscall" + "testing" + "time" +) + +// The signal path cannot be exercised in-process: delivering a signal to the +// test binary would race with the test framework, and the disposition changes +// are global. So the test re-executes itself as a child, and the child runs the +// same waitForStopSignal / shutdownServer the server does. +// +// The child deliberately serves an empty http.Server rather than the real one: +// this repository's CI has no database (.github/workflows/go.yml runs neither +// MySQL nor a sqlite-tagged build), and none of what is under test needs one. +const ( + childEnv = "GO_ADMIN_SIGNAL_CHILD" + childStuckEnv = "GO_ADMIN_SIGNAL_CHILD_STUCK" + childHangConn = "GO_ADMIN_SIGNAL_CHILD_HANGCONN" + markerReady = "CHILD-READY" + markerSignal = "CHILD-SIGNAL" + markerShutdown = "CHILD-SHUTDOWN-OK" + markerExiting = "CHILD-EXITING" +) + +// TestSignalChild is the child process. It is skipped in a normal run. +func TestSignalChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("child process entry point") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Println("listen:", err) + os.Exit(3) + } + srv := &http.Server{Handler: http.NewServeMux()} + go func() { _ = srv.Serve(ln) }() + + if os.Getenv(childHangConn) == "1" { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + fmt.Println("dial:", err) + os.Exit(5) + } + defer func() { _ = c.Close() }() + } + + // Arm before announcing readiness. Doing it the other way round leaves a + // window in which the parent's signal reaches the default handler and + // kills the child before any of this runs - which is exactly the failure + // this whole change is about, so the test must not reproduce it by + // accident. + quit, disarm := armStopSignals() + + fmt.Println(markerReady) + os.Stdout.Sync() + + sig := <-quit + disarm() + fmt.Println(markerSignal, sig) + os.Stdout.Sync() + + if os.Getenv(childStuckEnv) == "1" { + // Stand in for a cleanup hook that never finishes. The point of + // restoring the signal disposition is that a second signal still + // reaches the default handler and kills this. + time.Sleep(2 * time.Minute) + } + + timeout := shutdownTimeout + if os.Getenv(childHangConn) == "1" { + // A connection that has sent nothing keeps Shutdown busy: net/http + // only treats a StateNew connection as idle once it is more than five + // seconds old. A short budget makes the timeout deterministic without + // waiting out the real one. + timeout = 300 * time.Millisecond + } + + if err := shutdownServer(srv, timeout); err != nil { + // Deliberately not fatal, and deliberately not a bare return: the + // point is that whatever follows still runs. + fmt.Println("shutdown error:", err) + } else { + fmt.Println(markerShutdown) + } + fmt.Println(markerExiting) + os.Stdout.Sync() +} + +func startChild(t *testing.T, stuck bool, extraEnv ...string) (*exec.Cmd, *os.File, chan string) { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + cmd := exec.Command(os.Args[0], "-test.run=TestSignalChild", "-test.v") + cmd.Env = append(os.Environ(), childEnv+"=1") + if stuck { + cmd.Env = append(cmd.Env, childStuckEnv+"=1") + } + cmd.Env = append(cmd.Env, extraEnv...) + cmd.Stdout = w + cmd.Stderr = w + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + _ = w.Close() + + lines := make(chan string, 64) + go func() { + defer close(lines) + buf := make([]byte, 4096) + var acc strings.Builder + for { + n, err := r.Read(buf) + if n > 0 { + acc.Write(buf[:n]) + for { + s := acc.String() + i := strings.IndexByte(s, '\n') + if i < 0 { + break + } + lines <- s[:i] + acc.Reset() + acc.WriteString(s[i+1:]) + } + } + if err != nil { + if acc.Len() > 0 { + lines <- acc.String() + } + return + } + } + }() + + t.Cleanup(func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + _ = r.Close() + }) + return cmd, r, lines +} + +// await drains lines until one contains want, or the deadline passes. It +// returns everything it saw, so a failure says what the child actually did. +func await(t *testing.T, lines chan string, want string, d time.Duration) []string { + t.Helper() + var seen []string + deadline := time.After(d) + for { + select { + case l, ok := <-lines: + if !ok { + t.Fatalf("child output ended before %q; saw:\n%s", want, strings.Join(seen, "\n")) + } + seen = append(seen, l) + if strings.Contains(l, want) { + return seen + } + case <-deadline: + t.Fatalf("timed out waiting for %q; saw:\n%s", want, strings.Join(seen, "\n")) + } + } +} + +// Acceptance 19. Registering only os.Interrupt meant SIGTERM - the signal +// `docker stop`, Kubernetes and systemd all send - terminated the process +// before any of the shutdown path ran. Both must now reach it. +func TestBothSignalsRunTheShutdownPath(t *testing.T) { + for _, tc := range []struct { + name string + sig syscall.Signal + }{ + {"SIGINT", syscall.SIGINT}, + {"SIGTERM", syscall.SIGTERM}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd, _, lines := startChild(t, false) + await(t, lines, markerReady, 30*time.Second) + + if err := cmd.Process.Signal(tc.sig); err != nil { + t.Fatalf("signal: %v", err) + } + + await(t, lines, markerSignal, 10*time.Second) + await(t, lines, markerShutdown, 10*time.Second) + await(t, lines, markerExiting, 10*time.Second) + + if err := cmd.Wait(); err != nil { + t.Fatalf("child exited with %v, want a clean exit", err) + } + }) + } +} + +// Acceptance 20. quit is a buffered channel and signal.Notify stays armed, so +// without restoring the disposition a second signal only refills the buffer: +// once SIGTERM is registered, a shutdown that hangs could not be interrupted by +// anything short of SIGKILL. +func TestASecondSignalStillKillsAStuckShutdown(t *testing.T) { + cmd, _, lines := startChild(t, true) + await(t, lines, markerReady, 30*time.Second) + + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("first signal: %v", err) + } + await(t, lines, markerSignal, 10*time.Second) + + // The child is now inside a cleanup that will not finish on its own. + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("second signal: %v", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case err := <-done: + if err == nil { + t.Fatal("child exited cleanly; it was supposed to be killed by the second signal") + } + case <-time.After(15 * time.Second): + t.Fatal("the second signal did not kill a stuck shutdown - the escape hatch is gone") + } +} + +// Acceptance 21. srv.Shutdown reports an error exactly when connections were +// still in flight, and the old code answered that with log.Fatal - an +// unconditional os.Exit(1). Everything after it, which is where the cleanup +// hooks will hang, never ran. A failed Shutdown must not end the process. +func TestShutdownTimeoutDoesNotStopWhatFollows(t *testing.T) { + cmd, _, lines := startChild(t, false, childHangConn+"=1") + await(t, lines, markerReady, 30*time.Second) + + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("signal: %v", err) + } + await(t, lines, markerSignal, 10*time.Second) + + seen := await(t, lines, markerExiting, 20*time.Second) + + var timedOut bool + for _, l := range seen { + if strings.Contains(l, "shutdown error:") { + timedOut = true + } + } + if !timedOut { + t.Fatalf("Shutdown did not time out, so this test proves nothing; saw:\n%s", + strings.Join(seen, "\n")) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("child exited with %v after a failed Shutdown, want a clean exit", err) + } +} From 5c3c3907d553ad33e5f894228fadc20b845885d1 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 5 Sep 2026 16:42:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix=F0=9F=90=9B:=20arm=20the=20stop=20signa?= =?UTF-8?q?ls=20before=20announcing=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit split arming from waiting so a caller could arm first, wrote a comment saying a signal landing in between reaches the default handler and kills the process, used it that way in the subprocess test - and then left run() calling the combined helper after the whole readiness banner. The window it warned about was still there in the one place that ships. The signals are now armed before the server starts serving, and the wait happens where it did. The disposition is restored right after the first signal rather than deferred, so a shutdown that hangs can still be interrupted by a second one. waitForStopSignal goes away: run() was its only caller, and what was worth keeping from its comment is now on armStopSignals. Note that no test covers this ordering. The subprocess test drives armStopSignals directly, which is what makes it a test of the mechanism rather than of run(); moving the call back below the banner leaves it green. Verified by reading the sequence in run(), not by a failing test. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- cmd/api/server.go | 30 ++++++++++++++---------------- cmd/api/signal_test.go | 3 ++- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cmd/api/server.go b/cmd/api/server.go index de35e0ac..646b988b 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -115,6 +115,13 @@ func run() error { } } + // Armed before the server starts serving, and well before the readiness + // banner: a signal arriving between "the process is up" and "the process + // is listening for signals" reaches the default handler and kills it + // without any of the shutdown below. That window is the whole reason + // arming is separate from waiting. + quit, disarmStopSignals := armStopSignals() + go func() { // 服务连接 if config.SslConfig.Enable { @@ -137,7 +144,10 @@ func run() error { fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port) fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr()) - waitForStopSignal() + <-quit + // Restored here, not deferred: from this point a second signal must reach + // the default handler, so a shutdown that hangs can still be interrupted. + disarmStopSignals() log.Info("Shutdown Server ... ") if err := shutdownServer(srv, shutdownTimeout); err != nil { @@ -156,27 +166,15 @@ func run() error { // - `docker stop` allows 10s by default before it sends SIGKILL. const shutdownTimeout = 5 * time.Second -// waitForStopSignal blocks until the process is asked to stop. +// armStopSignals registers for the stop signals and returns the channel they +// arrive on together with the function that restores the default disposition. // // SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes // pod deletion and `systemctl stop` all send it, and Go terminates the process // immediately for a signal nobody listens for. Registering only os.Interrupt -// meant every graceful shutdown below this line was dead code outside a +// meant every graceful shutdown below the wait was dead code outside a // terminal. // -// The disposition is restored before returning, so a second signal kills the -// process the default way. The channel keeps the notification we already took, -// so without this a stuck shutdown would swallow every further signal - once -// SIGTERM is registered there is no escape hatch left but SIGKILL. -func waitForStopSignal() os.Signal { - quit, disarm := armStopSignals() - defer disarm() - return <-quit -} - -// armStopSignals registers for the stop signals and returns the channel they -// arrive on together with the function that restores the default disposition. -// // Registering is separate from waiting so a caller can arm before it announces // that it is ready: a signal that arrives between the two is delivered to the // default handler, which for both of these means the process dies without diff --git a/cmd/api/signal_test.go b/cmd/api/signal_test.go index 604b27c3..9d0f6a3f 100644 --- a/cmd/api/signal_test.go +++ b/cmd/api/signal_test.go @@ -15,7 +15,7 @@ import ( // The signal path cannot be exercised in-process: delivering a signal to the // test binary would race with the test framework, and the disposition changes // are global. So the test re-executes itself as a child, and the child runs the -// same waitForStopSignal / shutdownServer the server does. +// same armStopSignals / shutdownServer the server does. // // The child deliberately serves an empty http.Server rather than the real one: // this repository's CI has no database (.github/workflows/go.yml runs neither @@ -77,6 +77,7 @@ func TestSignalChild(t *testing.T) { timeout := shutdownTimeout if os.Getenv(childHangConn) == "1" { + // A connection that has sent nothing keeps Shutdown busy: net/http // only treats a StateNew connection as idle once it is more than five // seconds old. A short budget makes the timeout deterministic without From d3a44a2a6b7df7dd941d40929be284dd1fb6dbc5 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 5 Sep 2026 16:42:47 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test=E2=9C=85:=20dial=20the=20stalling=20co?= =?UTF-8?q?nnection=20after=20the=20signal,=20not=20at=20start-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net/http stops counting a StateNew connection against Shutdown once it is more than five seconds old. The connection was opened when the child started and the parent then waited for readiness before signalling, so on a slow run the connection could age past that mark and Shutdown would succeed - and the test would fail on its own "this proves nothing" guard rather than on the behaviour it is there to pin. Opening it immediately before the shutdown keeps the timeout deterministic. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- cmd/api/signal_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cmd/api/signal_test.go b/cmd/api/signal_test.go index 9d0f6a3f..09c74c25 100644 --- a/cmd/api/signal_test.go +++ b/cmd/api/signal_test.go @@ -44,15 +44,6 @@ func TestSignalChild(t *testing.T) { srv := &http.Server{Handler: http.NewServeMux()} go func() { _ = srv.Serve(ln) }() - if os.Getenv(childHangConn) == "1" { - c, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - fmt.Println("dial:", err) - os.Exit(5) - } - defer func() { _ = c.Close() }() - } - // Arm before announcing readiness. Doing it the other way round leaves a // window in which the parent's signal reaches the default handler and // kills the child before any of this runs - which is exactly the failure @@ -77,6 +68,16 @@ func TestSignalChild(t *testing.T) { timeout := shutdownTimeout if os.Getenv(childHangConn) == "1" { + // Dialled here, not at start-up. net/http stops counting a StateNew + // connection against Shutdown once it is more than five seconds old, + // so a connection opened before the wait would age out on a slow CI + // run and Shutdown would succeed - leaving the test asserting nothing. + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + fmt.Println("dial:", err) + os.Exit(5) + } + defer func() { _ = c.Close() }() // A connection that has sent nothing keeps Shutdown busy: net/http // only treats a StateNew connection as idle once it is more than five From b59c7f0d464134c3c11c2df1c9119aaa64300b80 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 5 Sep 2026 16:44:53 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test=E2=9C=85:=20wait=20for=20the=20accept,?= =?UTF-8?q?=20not=20just=20the=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the dial to just before the shutdown removed one flake and introduced another: Shutdown only waits for connections the server has already accepted, so calling it in the gap between the dial and the accept finds nothing to wait for and returns cleanly. The test then fails on its own "this proves nothing" guard - which it did, after passing once. A ConnState hook closes both gaps deterministically. The connection is opened late enough not to age past the five seconds net/http stops counting it at, and the child does not proceed until the server has taken it off the listener. Ran five times in a row rather than once, because a single green run is what made the previous version look fixed. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- cmd/api/signal_test.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cmd/api/signal_test.go b/cmd/api/signal_test.go index 09c74c25..3e3c5531 100644 --- a/cmd/api/signal_test.go +++ b/cmd/api/signal_test.go @@ -41,7 +41,22 @@ func TestSignalChild(t *testing.T) { fmt.Println("listen:", err) os.Exit(3) } - srv := &http.Server{Handler: http.NewServeMux()} + // accepted fires once the server has taken a connection off the listener. + // Dialling is not enough: Shutdown only waits for connections the server + // has already accepted, so calling it between the dial and the accept + // finds nothing to wait for and returns immediately. + accepted := make(chan struct{}, 1) + srv := &http.Server{ + Handler: http.NewServeMux(), + ConnState: func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + select { + case accepted <- struct{}{}: + default: + } + } + }, + } go func() { _ = srv.Serve(ln) }() // Arm before announcing readiness. Doing it the other way round leaves a @@ -79,6 +94,15 @@ func TestSignalChild(t *testing.T) { } defer func() { _ = c.Close() }() + // And wait for the accept, for the opposite reason: an unaccepted + // connection is not one Shutdown waits for either. + select { + case <-accepted: + case <-time.After(10 * time.Second): + fmt.Println("the server never accepted the stalling connection") + os.Exit(6) + } + // A connection that has sent nothing keeps Shutdown busy: net/http // only treats a StateNew connection as idle once it is more than five // seconds old. A short budget makes the timeout deterministic without