Files
Pat Whittingslow d05cd14018 httphi router refactor (#176)
* httphi: RouterConfig refactor to enable DefaultRouterConfig

* RequestHeader not case sensitive anymore

* httphi: use DefaultRouterConfig in examples

* httphi: remove gated stage complexity

Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods

* httphi: improve APIs

* httphi: remove status
2026-08-03 15:33:43 -07:00

502 lines
14 KiB
Go

package httphi
import (
"bytes"
"context"
"io"
"net"
"strings"
"sync"
"testing"
"time"
)
// rwconn is a in-memory conn. The router handles connections on another
// goroutine so every field is guarded; onClose lets tests await the handler.
//
// A drained rwconn reads (0,nil) as a live socket with no data pending would,
// so a partial request can be completed with AddReadable mid-handling. Tests
// that need the peer to hang up call Hangup.
type rwconn struct {
mu sync.Mutex
readable bytes.Buffer
segments []string
written bytes.Buffer
closed bool
hangup bool
failWr int
onClose chan struct{}
deadline time.Time
}
// newConn returns a conn preloaded with request and whose Close is observable
// with [rwconn.AwaitClose].
func newConn(request string) *rwconn {
r := &rwconn{onClose: make(chan struct{})}
r.AddReadable([]byte(request))
return r
}
// AddSegment queues data delivered on a later read, once everything already
// pending has been read. Models a request split over several TCP segments
// without depending on goroutine scheduling.
func (r *rwconn) AddSegment(b string) {
r.mu.Lock()
defer r.mu.Unlock()
r.segments = append(r.segments, b)
}
// FailWrites makes the next n writes fail, as a conn refusing further data
// would. Later writes succeed.
func (r *rwconn) FailWrites(n int) {
r.mu.Lock()
defer r.mu.Unlock()
r.failWr = n
}
// Hangup makes reads past the pending data return [io.EOF], as a peer that
// closed its side of the connection would.
func (r *rwconn) Hangup() {
r.mu.Lock()
defer r.mu.Unlock()
r.hangup = true
}
func (r *rwconn) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.closed {
r.closed = true
if r.onClose != nil {
close(r.onClose)
}
}
return nil
}
// AwaitClose blocks until the connection is closed by its handler or timeout elapses.
func (r *rwconn) AwaitClose(t *testing.T, timeout time.Duration) {
t.Helper()
select {
case <-r.onClose:
case <-time.After(timeout):
t.Fatal("timed out awaiting connection close by handler")
}
}
func (r *rwconn) Read(b []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return 0, net.ErrClosed
} else if r.deadlineExceeded() {
return 0, context.DeadlineExceeded
} else if r.readable.Len() == 0 {
if len(r.segments) > 0 {
r.readable.WriteString(r.segments[0])
r.segments = r.segments[1:]
return r.readable.Read(b)
}
if r.hangup {
return 0, io.EOF
}
return 0, nil // No data pending, handler backs off and retries.
}
return r.readable.Read(b)
}
func (r *rwconn) Write(b []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return 0, net.ErrClosed
} else if r.deadlineExceeded() {
return 0, context.DeadlineExceeded
} else if r.failWr > 0 {
r.failWr--
return 0, io.ErrShortWrite
}
return r.written.Write(b)
}
func (r *rwconn) deadlineExceeded() bool {
return !r.deadline.IsZero() && time.Since(r.deadline) > 0
}
func (r *rwconn) AddReadable(b []byte) {
r.mu.Lock()
defer r.mu.Unlock()
r.readable.Write(b)
}
// SetDeadline makes reads and writes past t fail, as a conn with a read
// deadline set would.
func (r *rwconn) SetDeadline(t time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
r.deadline = t
}
// IsClosed reports whether the connection was closed by its handler.
func (r *rwconn) IsClosed() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.closed
}
func (r *rwconn) ViewWritten() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.written.String()
}
var _ Mux = (*MuxSlice)(nil)
func configSynchronousRouter(t *testing.T, router *Router, bufferSize int, mux Mux) {
err := router.Configure(mux, RouterConfig{
FixedNumGoroutines: -1,
RequestHeaderBufferSize: bufferSize,
RequestNumHeaderKVCap: 16,
ResponseHeaderMinBufferSize: bufferSize,
})
if err != nil {
t.Fatal(err)
}
}
func TestRouterGet(t *testing.T) {
const bufferSize = 1024
const expectResponse = "its time"
var (
sm MuxSlice
router Router
)
sm.Handle("GET /", staticPage(t, expectResponse))
configSynchronousRouter(t, &router, bufferSize, &sm)
conn := newConn("GET / HTTP/1.1\r\nHost: tinygo.org\r\n\r\n")
err := router.Handle(conn)
if err != nil {
t.Fatal(err)
}
conn.AwaitClose(t, time.Second)
got := conn.ViewWritten()
if !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
t.Errorf("want 200 status line, got %q", got)
}
if !strings.HasSuffix(got, expectResponse) {
t.Errorf("want body %q at end of response, got %q", expectResponse, got)
}
}
// The handler observes the request line and header fields the router parsed.
func TestRouterRequestVisibleToHandler(t *testing.T) {
const bufferSize = 1024
var (
sm MuxSlice
router Router
)
var gotMethod, gotURI, gotHost string
var gotMethodEnum Method
sm.Handle("GET /index.html", func(ex *Exchange) {
gotMethod = string(ex.RequestMethodBytes())
gotMethodEnum = ex.RequestMethod()
gotURI = string(ex.RequestTarget())
gotHost = string(ex.RequestHeader("Host"))
ex.WriteHeader(200)
})
configSynchronousRouter(t, &router, bufferSize, &sm)
conn := newConn("GET /index.html HTTP/1.1\r\nHost: tinygo.org\r\n\r\n")
if err := router.Handle(conn); err != nil {
t.Fatal(err)
}
conn.AwaitClose(t, time.Second)
if gotMethod != "GET" {
t.Errorf("want method %q, got %q", "GET", gotMethod)
}
if gotMethodEnum != MethGet {
t.Errorf("want method enum %q, got %q", MethGet, gotMethodEnum)
}
if gotURI != "/index.html" {
t.Errorf("want URI %q, got %q", "/index.html", gotURI)
}
if gotHost != "tinygo.org" {
t.Errorf("want Host %q, got %q", "tinygo.org", gotHost)
}
}
// Router must route on method and URI, and must not invoke a handler for
// requests it has no registration for.
func TestRouterMux(t *testing.T) {
const bufferSize = 1024
for _, test := range []struct {
name string
request string
want string // Response body the matched handler must have written.
wantNoHandler bool // No registration matches: the router must answer 404 itself.
}{
{name: "get root", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n", want: "root"},
{name: "get page", request: "GET /page HTTP/1.1\r\nHost: h\r\n\r\n", want: "page"},
{name: "any method", request: "DELETE /any HTTP/1.1\r\nHost: h\r\n\r\n", want: "any"},
{name: "method mismatch", request: "POST / HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
{name: "unknown uri", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
} {
t.Run(test.name, func(t *testing.T) {
var (
sm MuxSlice
router Router
)
// "/{$}" is the root alone: a bare "/" is a catch-all and would serve
// "root" for /page and /nowhere as well, see [SetPathValues].
sm.Handle("GET /{$}", staticPage(t, "root"))
sm.Handle("GET /page", staticPage(t, "page"))
sm.Handle("/any", staticPage(t, "any")) // No method: matches any.
configSynchronousRouter(t, &router, bufferSize, &sm)
conn := newConn(test.request)
if err := router.Handle(conn); err != nil {
t.Fatal(err)
}
conn.AwaitClose(t, time.Second)
got := conn.ViewWritten()
_, body, found := strings.Cut(got, "\r\n\r\n")
if !found {
t.Fatalf("header block never terminated: %q", got)
}
if test.wantNoHandler {
if !strings.HasPrefix(got, "HTTP/1.1 404 ") {
t.Errorf("want a 404 answer, got %q", got)
}
if body != "" {
t.Errorf("no handler must run, got body %q", body)
}
return
}
if !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
t.Errorf("want a 200 answer, got %q", got)
}
if body != test.want {
t.Errorf("want body %q, got %q", test.want, body)
}
})
}
}
// A request arriving in pieces (TCP segmentation) must still be handled.
func TestRouterSplitRequest(t *testing.T) {
const bufferSize = 1024
const expectResponse = "split ok"
var (
sm MuxSlice
router Router
)
sm.Handle("GET /", staticPage(t, expectResponse))
configSynchronousRouter(t, &router, bufferSize, &sm)
conn := newConn("GET / HTTP/1.1\r\nHo")
conn.AddSegment("st: tinygo.org\r\n\r")
conn.AddSegment("\n") // Final CRLF lands in its own segment.
if err := router.Handle(conn); err != nil {
t.Fatal(err)
}
conn.AwaitClose(t, time.Second)
if got := conn.ViewWritten(); !strings.HasSuffix(got, expectResponse) {
t.Errorf("want body %q, got response %q", expectResponse, got)
}
}
func staticPage(t *testing.T, page string) HandlerFunc {
return func(ex *Exchange) {
var rw ExchangeRW // Streaming APIs take the ReadWriter view.
ex.ReadWriter(&rw)
n, err := io.WriteString(&rw, page)
// Handler runs on the router goroutine: Error, never Fatal.
if err != nil {
t.Error(err)
} else if n != len(page) {
t.Error("expected written ", len(page), "got", n)
}
}
}
// An exchange freed by the outgoing generation carries that generation's
// buffers. Recycling it under a new configuration serves the request with
// buffer limits the new [RouterConfig] never asked for.
func TestRouterReconfigureDropsStaleExchanges(t *testing.T) {
const smallBuf, largeBuf = 256, 1024
var (
sm MuxSlice
router Router
)
bufsize := make(chan int, 2)
sm.Handle("GET /", func(ex *Exchange) { bufsize <- len(ex.UnsafeRawBuffer()) })
serve := func(want int) {
t.Helper()
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
conn.Hangup()
if err := router.Handle(conn); err != nil {
t.Fatal(err)
}
if got := <-bufsize; got != want {
t.Errorf("want exchange buffer %d, got %d", want, got)
}
conn.AwaitClose(t, time.Second) // Exchange hits the freelist on close.
}
configSynchronousRouter(t, &router, smallBuf, &sm)
serve(2 * smallBuf)
configSynchronousRouter(t, &router, largeBuf, &sm)
serve(2 * largeBuf)
}
// Configure writes the fields Handle reads; concurrent use must not race.
func TestRouterConfigureHandleRace(t *testing.T) {
const bufferSize = 1024
var (
sm MuxSlice
router Router
)
sm.Handle("GET /", staticPage(t, "ok"))
configSynchronousRouter(t, &router, bufferSize, &sm)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
configSynchronousRouter(t, &router, bufferSize, &sm)
}()
go func() {
defer wg.Done()
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
if err := router.Handle(conn); err != nil {
t.Error(err)
}
}()
wg.Wait()
}
// Reconfiguring a running router tears down the job queue that Handle may be
// sending a connection on. Connections may be dropped, but never panic.
// A torn down router has nothing left to serve with: it must say so instead of
// dropping the connection as if it were merely busy.
func TestRouterHandleAfterTeardown(t *testing.T) {
var (
sm MuxSlice
router Router
)
sm.Handle("GET /", staticPage(t, "ok"))
err := router.Configure(&sm, RouterConfig{
FixedNumGoroutines: 2,
RequestHeaderBufferSize: 512,
RequestNumHeaderKVCap: 16,
ResponseHeaderMinBufferSize: 512,
})
if err != nil {
t.Fatal(err)
}
router.Shutdown()
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
if err = router.Handle(conn); err != errRouterTornDown {
t.Errorf("want errRouterTornDown, got %v", err)
}
if conn.IsClosed() {
t.Error("refused connection must be left for the caller to dispose of")
}
}
// Tearing down a generation abandons the connections queued for it. They were
// taken ownership of by Handle, so they must be closed and their exchanges
// released: an exchange left claimed by a torn down generation is a buffer the
// router can never reconfigure again.
func TestRouterTeardownReleasesQueuedConns(t *testing.T) {
var (
sm MuxSlice
router Router
)
const numGoro = 2
sm.Handle("GET /", staticPage(t, "ok"))
cfg := RouterConfig{
FixedNumGoroutines: numGoro,
RequestHeaderBufferSize: 512,
RequestNumHeaderKVCap: 16,
ResponseHeaderMinBufferSize: 512,
}
// Handing connections over and tearing down immediately leaves them queued
// for workers that will never serve them. Rounds bound the scheduling luck
// needed; a single leaked exchange also fails every later Configure.
var conns [numGoro]*rwconn
for range 30 {
// errBusyExchanges is legitimate backpressure while the previous
// generation drops its connections, but it must not outlive it.
var err error
for range 100 {
if err = router.Configure(&sm, cfg); err == nil {
break
}
time.Sleep(time.Millisecond)
}
if err != nil {
t.Fatal("reconfigure after teardown:", err)
}
for i := range conns {
conns[i] = newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
conns[i].Hangup()
if err := router.Handle(conns[i]); err != nil {
t.Fatal(err)
}
}
router.Shutdown()
for i := range conns {
// Handle took ownership of the connection: served or dropped, the
// router closes it.
conns[i].AwaitClose(t, time.Second)
}
}
}
func TestRouterConfigureDuringWorkerHandle(t *testing.T) {
var (
sm MuxSlice
router Router
)
sm.Handle("GET /", staticPage(t, "ok"))
cfg := RouterConfig{
FixedNumGoroutines: 2,
RequestHeaderBufferSize: 512,
RequestNumHeaderKVCap: 16,
ResponseHeaderMinBufferSize: 512,
}
if err := router.Configure(&sm, cfg); err != nil {
t.Fatal(err)
}
defer router.Shutdown()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for range 300 {
conn := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
conn.Hangup()
router.Handle(conn) // Drops are fine, panics are not.
}
}()
go func() {
defer wg.Done()
// Each Configure sleeps 5ms tearing down the previous generation, keep
// the count low and let the Handle loop supply the concurrency.
for range 20 {
// errBusyExchanges is legitimate backpressure: the previous
// generation was still serving when the buffers were needed.
if err := router.Configure(&sm, cfg); err != nil && err != errBusyExchanges {
t.Error(err)
return
}
}
}()
wg.Wait()
}