server: assert timeouts on the served http.Server, not just the constructor (closes #120)
check / check (push) Failing after 1s

The timeout tests called newHTTPServer directly, leaving Run's call
site unguarded: a Run that built its http.Server inline would drop
every timeout with the suite still green. TestRunWiresSocketTimeouts
now wires a Server as cmd/dnswatcher does (minus watcher and resolver,
so no live DNS), drives Run with an unbindable port so it stores its
http.Server and returns without listening, and asserts the served
server carries all four timeouts and both required relationships.
Reverting Run to an inline http.Server without timeouts fails it. The
near-tautological addr/handler test is dropped; the wiring test covers
the handler end to end.

The read-deadline note is corrected: a too-small ReadTimeout does not
make ReadHeaderTimeout unreachable (net/http applies it directly); it
installs an already-expired whole-request deadline. Verified against
the pinned go1.25 net/http.

Model: opus-4-8
This commit is contained in:
2026-09-21 07:51:28 +00:00
parent b351a2350c
commit 71b8d46f7b
3 changed files with 122 additions and 88 deletions
+5
View File
@@ -23,8 +23,13 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps # Completed Steps
- 2026-09-21: server timeout test now drives `Run` and asserts the
served `http.Server` carries the timeouts; corrected the inverted
`ReadTimeout` rationale note (#120).
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` - 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
`// indirect` line so `script/bootstrap` leaves a clean tree (#132) `// indirect` line so `script/bootstrap` leaves a clean tree (#132)
- 2026-08-10: comment-only corrections to `script/bootstrap`, - 2026-08-10: comment-only corrections to `script/bootstrap`,
`script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in `script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in
`script/bootstrap` was justified by a claim that `script/fmt-check` `script/bootstrap` was justified by a claim that `script/fmt-check`
+13 -8
View File
@@ -5,15 +5,20 @@ import (
"time" "time"
) )
// NewHTTPServer exports newHTTPServer for testing.
func NewHTTPServer(
listenAddr string,
handler http.Handler,
) *http.Server {
return newHTTPServer(listenAddr, handler)
}
// RequestTimeout exports the handler execution budget applied by // RequestTimeout exports the handler execution budget applied by
// chimw.Timeout in SetupRoutes, so tests can assert the relationship // chimw.Timeout in SetupRoutes, so tests can assert the relationship
// between it and the server's WriteTimeout. // between it and the server's WriteTimeout.
const RequestTimeout time.Duration = requestTimeout const RequestTimeout time.Duration = requestTimeout
// SetListenPort overrides the port Run binds. A test uses it to hand
// Run an unbindable port so ListenAndServe fails immediately and Run
// returns after storing its http.Server.
func SetListenPort(s *Server, port int) {
s.port = port
}
// HTTPServerOf returns the http.Server that Run built and stored, so a
// test can inspect the timeouts the running server actually carries.
func HTTPServerOf(s *Server) *http.Server {
return s.httpServer
}
+104 -80
View File
@@ -1,112 +1,136 @@
package server_test package server_test
import ( import (
"net/http"
"testing" "testing"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/handlers"
"sneak.berlin/go/dnswatcher/internal/healthcheck"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/middleware"
"sneak.berlin/go/dnswatcher/internal/notify"
"sneak.berlin/go/dnswatcher/internal/server" "sneak.berlin/go/dnswatcher/internal/server"
"sneak.berlin/go/dnswatcher/internal/state"
) )
// noopHandler stands in for the router; newHTTPServer only stores it. // buildServer wires a *server.Server exactly as cmd/dnswatcher does,
func noopHandler() http.Handler { // minus the watcher/resolver subtree that would touch live DNS. fx
return http.HandlerFunc( // builds the object graph but the lifecycle is never started, so no
func(w http.ResponseWriter, _ *http.Request) { // OnStart hook runs and nothing listens or resolves. The caller must
w.WriteHeader(http.StatusOK) // first configure viper (config.New reads it), which is also why the
}, // caller cannot run in parallel.
func buildServer(t *testing.T) *server.Server {
t.Helper()
var srv *server.Server
app := fx.New(
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
config.New,
state.New,
healthcheck.New,
notify.New,
middleware.New,
handlers.New,
server.New,
),
fx.Populate(&srv),
) )
}
// TestHTTPServerTimeoutsAreSet asserts that every socket-level err := app.Err()
// timeout is configured. A zero value in net/http means "no limit", if err != nil {
// so a refactor that silently drops one of these reintroduces the t.Fatalf("building server graph: %v", err)
// slowloris / unreaped-keep-alive exposure this guards against.
//
// The assertions are on the configured field values only; nothing
// here measures elapsed time, so the test cannot flake on timing.
func TestHTTPServerTimeoutsAreSet(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.ReadTimeout <= 0 {
t.Errorf(
"ReadTimeout must be non-zero, got %v",
srv.ReadTimeout,
)
} }
if srv.ReadHeaderTimeout <= 0 { return srv
}
// TestRunWiresSocketTimeouts pins that the http.Server the running
// server actually serves — the one Run builds and hands to
// ListenAndServe — carries every socket-level timeout, plus the two
// relationships the values must satisfy. Earlier tests asserted these
// on newHTTPServer directly, which left the call site unguarded: a Run
// that built its http.Server inline would drop every timeout with the
// suite still green (https://git.eeqj.de/sneak/dnswatcher/issues/120).
//
// Run is driven to completion with an unbindable port: it builds and
// stores s.httpServer, then ListenAndServe fails at once and Run
// returns without ever listening. The assertions run in the same
// goroutine after Run returns, so reading s.httpServer is free of any
// data race. Nothing here measures elapsed time.
//
// On the ReadTimeout >= ReadHeaderTimeout relationship: a smaller
// ReadTimeout does NOT make the header phase unreachable. net/http's
// (*Server).readHeaderTimeout applies ReadHeaderTimeout directly, so
// the header read keeps its full budget. What breaks is the
// whole-request deadline: once the headers are read, readRequest
// installs a read deadline of t0+ReadTimeout, which is already in the
// past when ReadTimeout is the smaller value, severing the request.
// Verified against the pinned go1.25 net/http (Dockerfile golang
// 1.25-alpine; go.mod go 1.25.5): src/net/http/server.go readRequest
// and (*Server).readHeaderTimeout.
func TestRunWiresSocketTimeouts(t *testing.T) {
// Sets an env var and touches viper global state, so like the
// config tests it cannot use t.Parallel.
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
srv := buildServer(t)
server.SetListenPort(srv, -1)
srv.Run()
hs := server.HTTPServerOf(srv)
if hs == nil {
t.Fatal("Run did not build an http.Server")
}
if hs.ReadTimeout <= 0 {
t.Errorf("ReadTimeout must be non-zero, got %v", hs.ReadTimeout)
}
if hs.ReadHeaderTimeout <= 0 {
t.Errorf( t.Errorf(
"ReadHeaderTimeout must be non-zero, got %v", "ReadHeaderTimeout must be non-zero, got %v",
srv.ReadHeaderTimeout, hs.ReadHeaderTimeout,
) )
} }
if srv.WriteTimeout <= 0 { if hs.WriteTimeout <= 0 {
t.Errorf( t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout)
"WriteTimeout must be non-zero, got %v",
srv.WriteTimeout,
)
} }
if srv.IdleTimeout <= 0 { if hs.IdleTimeout <= 0 {
t.Errorf( t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout)
"IdleTimeout must be non-zero, got %v",
srv.IdleTimeout,
)
} }
}
// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the if hs.WriteTimeout <= server.RequestTimeout {
// values must satisfy. net/http arms the write deadline once request
// headers are read, so it covers handler execution plus the response
// flush. If WriteTimeout were not greater than the chimw.Timeout
// handler budget, the connection would be severed before a handler
// that used its full budget could respond, making that budget
// unreachable.
func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.WriteTimeout <= server.RequestTimeout {
t.Errorf( t.Errorf(
"WriteTimeout (%v) must exceed handler budget (%v)", "WriteTimeout (%v) must exceed handler budget (%v)",
srv.WriteTimeout, hs.WriteTimeout,
server.RequestTimeout, server.RequestTimeout,
) )
} }
}
// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for if hs.ReadTimeout < hs.ReadHeaderTimeout {
// the whole request is at least as long as the header-only deadline;
// a smaller ReadTimeout would make ReadHeaderTimeout unreachable.
func TestReadTimeoutCoversHeaderTimeout(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":8080", noopHandler())
if srv.ReadTimeout < srv.ReadHeaderTimeout {
t.Errorf( t.Errorf(
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)", "ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
srv.ReadTimeout, hs.ReadTimeout,
srv.ReadHeaderTimeout, hs.ReadHeaderTimeout,
)
}
if hs.Handler != srv {
t.Errorf(
"Run wired handler %T, want the *server.Server",
hs.Handler,
) )
} }
} }
// TestHTTPServerAddrAndHandler covers the rest of the constructor so
// a future edit cannot drop the listen address or the handler.
func TestHTTPServerAddrAndHandler(t *testing.T) {
t.Parallel()
srv := server.NewHTTPServer(":9999", noopHandler())
if srv.Addr != ":9999" {
t.Errorf("Addr = %q, want %q", srv.Addr, ":9999")
}
if srv.Handler == nil {
t.Error("Handler must not be nil")
}
}