132 lines
5.2 KiB
Go
132 lines
5.2 KiB
Go
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
|
|
}
|