package httpapi import ( "context" "errors" "log/slog" "net" "net/http" "time" ) // Timeouts of the listening server. A named type rather than literals inside main: the test floor // asserts this class of defect on a REAL server, and a *http.Server built inside func main is not // reachable from a test (PD-2 survived P0 for exactly that reason). type Timeouts struct { ReadHeader time.Duration // Read bounds the WHOLE request, body included. Without it a client can pin a connection // forever, and it does not need a body-reading handler to do it: net/http drains an unread // body inside chunkWriter.writeHeader, before the response goes out, and that read inherits // the connection deadline. // // It does NOT bound a long RESPONSE, and a streaming handler needs nothing from it: net/http // clears the deadline itself once the request has arrived — before the handler when nothing // remains to read, at body EOF otherwise (server.go:2059-2062) — and nothing re-arms it while // the handler runs. Nor may a handler clear it by hand: on a half-fed request that drain is // the only bound left, and clearing the deadline before the header write hangs the handler // inside WriteHeader for as long as the client keeps the socket. Measured both ways (PD-51). Read time.Duration Idle time.Duration // Shutdown is how long a drain may take before in-flight handlers lose their context. Shutdown time.Duration } // DefaultTimeouts is what the daemon runs with. // // No WriteTimeout: the SSE stream is a long-lived response and a write deadline set here would cut // it. Read is deliberately short — a request that legitimately takes longer than this to ARRIVE is // an upload, and an upload extends its own deadline as it makes progress. func DefaultTimeouts() Timeouts { return Timeouts{ ReadHeader: 10 * time.Second, Read: 30 * time.Second, Idle: 2 * time.Minute, Shutdown: 15 * time.Second, } } // Server owns the listener lifecycle: serve, then drain, then cancel. type Server struct { http *http.Server stopBase context.CancelFunc grace time.Duration log *slog.Logger } // NewServer configures the listening server the daemon runs. The handler is whatever New returned. // // It takes no Timeouts on purpose. When it did, main passed DefaultTimeouts() and every test passed // its own, so the one call that decided what SHIPPED was the one nothing observed — and replacing it // with a bare Timeouts{} left the whole battery green while the binary re-acquired PD-2 (measured). // With nothing to pass there is nothing to get wrong, and the test floor asserts on this very // constructor. Tests that need short deadlines use serverWithTimeouts. func NewServer(addr string, h http.Handler, log *slog.Logger) *Server { return serverWithTimeouts(addr, h, log, DefaultTimeouts()) } func serverWithTimeouts(addr string, h http.Handler, log *slog.Logger, t Timeouts) *Server { // The base context is NOT the signal context (PD-9): a signal must start the drain, not end // every in-flight request at once. It is cancelled after Shutdown returns, which is the point // where the grace period is spent and a still-running handler is one we no longer wait for. base, cancel := context.WithCancel(context.Background()) return &Server{ http: &http.Server{ Addr: addr, Handler: h, ReadHeaderTimeout: t.ReadHeader, ReadTimeout: t.Read, IdleTimeout: t.Idle, MaxHeaderBytes: 1 << 16, BaseContext: func(net.Listener) context.Context { return base }, // net/http's own errors (bad TLS records, malformed requests) reach slog instead of // the default logger's stderr, where nothing structured would find them. ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), }, stopBase: cancel, grace: t.Shutdown, log: log, } } // Listen opens the configured address. Separate from Run so that a caller — the daemon, a test — // knows the port is bound (and, with :0, which one) before anything is served on it. func (s *Server) Listen(ctx context.Context) (net.Listener, error) { var lc net.ListenConfig return lc.Listen(ctx, "tcp", s.http.Addr) } // Run serves until ctx is cancelled, then drains for the grace period. Returns nil on a clean stop. func (s *Server) Run(ctx context.Context, ln net.Listener) error { errc := make(chan error, 1) go func() { errc <- s.http.Serve(ln) }() select { case err := <-errc: s.stopBase() if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): } s.log.Info("shutting down", "grace_seconds", int(s.grace.Seconds())) started := time.Now() shutdownCtx, cancel := context.WithTimeout(context.Background(), s.grace) defer cancel() err := s.http.Shutdown(shutdownCtx) s.stopBase() if errors.Is(err, context.DeadlineExceeded) { // A drain that ran out of time is a slow request, not a failed service. Returning the error // makes the process exit non-zero, and under Restart=on-failure an ordinary stop then reads // to systemd as a crash. s.log.Warn("stopped: drain deadline exceeded, in-flight requests were cancelled", "after_ms", time.Since(started).Milliseconds()) return nil } if err != nil { return err } s.log.Info("stopped", "drained_ms", time.Since(started).Milliseconds()) return nil }