textmachine/platform/internal/httpapi/serve.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
}