mirror of
https://github.com/soypat/lneto.git
synced 2026-08-04 15:07:52 +00:00
d05cd14018
* 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
502 lines
14 KiB
Go
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()
|
|
}
|