diff --git a/examples/httphi-fuzz/main-httplinux.go b/examples/httphi-fuzz/main-httplinux.go index 3373b5e..03c1b07 100644 --- a/examples/httphi-fuzz/main-httplinux.go +++ b/examples/httphi-fuzz/main-httplinux.go @@ -18,14 +18,10 @@ import ( ) const ( - kB = 1 << 10 - listenPort = 8080 - bufferSizes = 2 * kB - // A browser sends around twenty header fields; a request carrying more - // than this is answered 431 rather than parsed into memory it was not - // given. Each field costs 8 bytes of table. - numHeaderFields = 32 - readTimeout = 2 * time.Second + kB = 1 << 10 + listenPort = 8080 + memoryPerConn = 4 * kB + readTimeout = 2 * time.Second ) // Credentials the endpoints check. They are in the source on purpose: this is a @@ -84,14 +80,8 @@ func run() error { server.Handle("/echo", server.echo) // No method: any method matches. var router httphi.Router - err = router.Configure(httphi.RouterConfig{ - FixedNumGoroutines: *flagThreads, - RequestHeaderBufferSize: bufferSizes, - RequestNumHeaderKVCap: numHeaderFields, - ResponseHeaderMinBufferSize: bufferSizes, - Mux: &server.mux, - Logger: slog.Default(), - }) + cfg := httphi.DefaultRouterConfig(*flagThreads, memoryPerConn, server.mux.MaxPathValues()) + err = router.Configure(&server.mux, cfg) if err != nil { return err } @@ -435,7 +425,7 @@ func (sv *Server) upload(exch *httphi.Exchange) { func (sv *Server) echo(exch *httphi.Exchange) { s := sv.acquireScratch() defer sv.releaseScratch(s) - body := append(s.out[:0], exch.RequestMethodRaw()...) + body := append(s.out[:0], exch.RequestMethodBytes()...) body = append(body, ' ') body = append(body, exch.RequestTarget()...) body = append(body, '\n') diff --git a/examples/httphi-over-ethernet/main.go b/examples/httphi-over-ethernet/main.go index dab3a02..fae13df 100644 --- a/examples/httphi-over-ethernet/main.go +++ b/examples/httphi-over-ethernet/main.go @@ -38,12 +38,7 @@ var indexhtml string // Router memory. The router allocates all of it on Configure and never again, // so these are the whole cost of serving HTTP over the stack. const ( - // A browser sends around 700 bytes of header on a landing page request. - requestHeaderBuffer = 1024 - // Response headers reuse whatever the request left unused on top of this, - // and the status line does not count towards it. - responseHeaderBuffer = 256 - numHeaderFields = 16 + httpConnMemoryUse = 4 * 1024 // One exchange is allocated per worker, and a worker holds its exchange for // the whole request, so this is what bounds requests served at once. numWorkers = 2 @@ -252,14 +247,9 @@ func run() (err error) { server.handle("GET /stats", server.stats) var router httphi.Router - err = router.Configure(httphi.RouterConfig{ - FixedNumGoroutines: numWorkers, - RequestHeaderBufferSize: requestHeaderBuffer, - ResponseHeaderMinBufferSize: responseHeaderBuffer, - RequestNumHeaderKVCap: numHeaderFields, - Mux: &server.mux, - Logger: slog.Default(), - }) + cfg := httphi.DefaultRouterConfig(numWorkers, httpConnMemoryUse, server.mux.MaxPathValues()) + cfg.Logger = slog.Default() + err = router.Configure(&server.mux, cfg) if err != nil { return fmt.Errorf("configuring HTTP router: %w", err) } @@ -322,7 +312,7 @@ type httpServer struct { func (sv *httpServer) handle(pattern string, handler httphi.HandlerFunc) { sv.mux.Handle(pattern, func(exch *httphi.Exchange) { sv.served.Add(1) - fmt.Printf("< %s %s\n", exch.RequestMethodRaw(), exch.RequestTarget()) + fmt.Printf("< %s %s\n", exch.RequestMethodBytes(), exch.RequestTarget()) handler(exch) }) } diff --git a/examples/httphi-server/main-httplinux.go b/examples/httphi-server/main-httplinux.go index c73a4e1..4b81e57 100644 --- a/examples/httphi-server/main-httplinux.go +++ b/examples/httphi-server/main-httplinux.go @@ -14,15 +14,11 @@ import ( ) const ( - kB = 1 << 10 - listenPort = 8080 - bufferSizes = 2 * kB - // A browser sends around twenty header fields; a request carrying more - // than this is answered 431 rather than parsed into memory it was not - // given. Each field costs 8 bytes of table. - numHeaderFields = 32 - numGoroutines = 4 - readTimeout = 2 * time.Second + kB = 1 << 10 + listenPort = 8080 + connMemoryUse = 4 * kB + numGoroutines = 4 + readTimeout = 2 * time.Second ) func main() { @@ -45,14 +41,8 @@ func run() error { server.Handle("GET /", server.homepage) var router httphi.Router - err = router.Configure(httphi.RouterConfig{ - FixedNumGoroutines: numGoroutines, - RequestHeaderBufferSize: bufferSizes, - RequestNumHeaderKVCap: numHeaderFields, - ResponseHeaderMinBufferSize: bufferSizes, - Mux: &server.mux, - Logger: slog.Default(), - }) + cfg := httphi.DefaultRouterConfig(numGoroutines, connMemoryUse, server.mux.MaxPathValues()) + err = router.Configure(&server.mux, cfg) if err != nil { return err } diff --git a/http/httphi/README.md b/http/httphi/README.md index 6a43c5e..3e0da34 100644 --- a/http/httphi/README.md +++ b/http/httphi/README.md @@ -22,13 +22,8 @@ mux.Handle("GET /", func(ex *httphi.Exchange) { }) var router httphi.Router -err := router.Configure(httphi.RouterConfig{ - FixedNumGoroutines: 4, // 4 workers, 4 exchanges, allocated here and never again. - RequestHeaderBufferSize: 1024, - ResponseHeaderMinBufferSize: 32, // Shares the request buffer. - RequestNumHeaderKVCap: 32, - Mux: &mux, -}) +cfg := httphi.DefaultRouterConfig(numWorkers, memoryPerConn, mux.MaxPathValues()) +err := router.Configure(&mux, cfg) if err != nil { log.Fatal(err) } diff --git a/http/httphi/example_test.go b/http/httphi/example_test.go index 33944e7..f20be5e 100644 --- a/http/httphi/example_test.go +++ b/http/httphi/example_test.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "log" - "log/slog" "net" "os" @@ -15,23 +14,15 @@ import ( // ExampleRouter_linux goes over how to setup a linux server using raw linux connections. // See [ExampleMuxSlice_query_forms_multipart] on how to define handlers for common HTTP processing. func ExampleRouter() { - // Chrome tends to send ~700 bytes on a typical landing page request. - const requestBuffer = 1024 - const numHeaderKV = requestBuffer / 32 // + const numWorkers = 8 + const memoryPerConn = 2048 var mux httphi.MuxSlice mux.Handle("GET /", func(ex *httphi.Exchange) { ex.WriteBody([]byte("hello world")) }) var router httphi.Router - err := router.Configure(httphi.RouterConfig{ - FixedNumGoroutines: -1, // Unbounded goroutines and allocations. - RequestHeaderBufferSize: requestBuffer, - ResponseHeaderMinBufferSize: 32, // Shared buffer with Request, not strictly necessary, especially if not sending headers. - RequestNumHeaderKVCap: numHeaderKV, - NormalizeOutgoingKeys: true, - Mux: &mux, - Logger: slog.Default(), - }) + cfg := httphi.DefaultRouterConfig(numWorkers, memoryPerConn, mux.MaxPathValues()) + err := router.Configure(&mux, cfg) if err != nil { log.Fatal(err) } diff --git a/http/httphi/exchange.go b/http/httphi/exchange.go index 289c28b..60c91d2 100644 --- a/http/httphi/exchange.go +++ b/http/httphi/exchange.go @@ -75,10 +75,8 @@ type ExchangeConfig struct { // Optional [any] cap holding the request header to RequestBufferLim rather than // growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted]. NoRequestBufferGrowth bool - // Conditional [>=the most wildcards any one registered pattern binds] number of - // path values bindable, read back with [Exchange.PathValue]. A pattern binding - // more never matches, see [SetPathValues]. Zero suits a mux of literal patterns. - MaxPathValues int + // Conditional [len >=[Mux.MaxPathValues]] written to during [Mux.LookupHandler] in [Handle]. + PathValuesBuf []PathValue } // HijackRaw is a low-level implementation of http.Hijacker interface. @@ -124,8 +122,7 @@ func (exch *Exchange) Configure(cfg ExchangeConfig) { exch.reqHdr.Reset(cfg.RawBuf[:0:cfg.RequestBufferLim], cfg.NumHeaderKVCap) exch.reqHdr.ConfigBufferGrowth(!cfg.NoRequestBufferGrowth) exch.normalizeKeys = cfg.NormalizeOutgoingKeys - internal.SliceReuse(&exch.pathValues, cfg.MaxPathValues) - exch.pathValues = exch.pathValues[:cfg.MaxPathValues] + exch.pathValues = cfg.PathValuesBuf } // Acquire claims the exchange for conn and resets it to serve a new request, @@ -170,27 +167,20 @@ func (exch *Exchange) Release() { // Does not return the buffer used for the response first line so can be safely // written to and used without modifying the staged response first line. // -// Staging headers will write to this buffer so use mindfully. // To access only the request header buffer portion use [httpraw.HeaderV1.BufferRaw] limited // to [httpraw.HeaderV1.BufferParsed] as returned by [Exchange.requestHeaderRaw]. -// Writing to this section will not change the contents read by [Exchange.ReadBody]. +// Writing to this aforementioned section will not change the contents read by [Exchange.ReadBody]. // // In [Router] context, the size of this buffer is influenced directly by [RouterConfig] HeaderBufferSize fields. func (exch *Exchange) UnsafeRawBuffer() []byte { return exch.rawbuf } -// RequestHeaderV1Raw returns the parsed request header for access beyond the -// Request* methods, such as [httpraw.HeaderV1.ForEach]. Valid until the exchange -// is released, and writing to it corrupts the response. +// RequestHeaderV1Raw returns the internal [Exchange] data structure used for HTTP/1.x requests. func (exch *Exchange) RequestHeaderV1Raw() *httpraw.HeaderV1 { return &exch.reqHdr } // StageHeader stages a response header field, written on the first // [Exchange.FlushHeader], [Exchange.WriteHeader] or [Exchange.WriteBody]. // Returns false and drops the field if the response buffer cannot fit it. -// Has no effect once the header has been written. func (exch *Exchange) StageHeader(key, value string) (enoughMemory bool) { - if exch.headerWritten { - return false - } off := int(exch.respHeaderOff) + int(exch.respHeaderLen) free := len(exch.rawbuf) - off // Field costs key+':'+value+CRLF, plus the CRLF [Exchange.FlushHeader] @@ -230,7 +220,7 @@ func (exch *Exchange) StageHeaderInt(key string, value int64) (enoughMemory bool // base must be in the range 10..36; lower bases are dropped, no HTTP header // field value is written below base 10. func (exch *Exchange) StageHeaderIntBase(key string, value int64, base int) (enoughMemory bool) { - if exch.headerWritten || base < 10 || base > 36 { + if base < 10 || base > 36 { return false } off := int(exch.respHeaderOff) + int(exch.respHeaderLen) @@ -255,9 +245,9 @@ func (exch *Exchange) StageHeaderIntBase(key string, value int64, base int) (eno // StageStatus prepares the status line for the given code without writing // it, i.e: "HTTP/1.1 404 Not Found". Codes with no [StatusText] get an empty -// reason phrase. Has no effect once the header has been written. +// reason phrase. func (exch *Exchange) StageStatus(code int) { - if code >= 1000 || exch.headerWritten { + if code >= 1000 { return } else if code == 200 { // Common case. @@ -278,11 +268,8 @@ func (exch *Exchange) StageStatus(code int) { // WriteHeader sends the status line for code along with the staged header // fields. Only the first call reaches the wire, as in http.ResponseWriter. func (exch *Exchange) WriteHeader(code int) (n int, err error) { - if !exch.headerWritten { - exch.StageStatus(code) - n, err = exch.FlushHeader() - } - return n, err + exch.StageStatus(code) + return exch.FlushHeader() } // Respond writes a complete response in one call: Content-Type, a Content-Length @@ -497,7 +484,7 @@ func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error func (exch *Exchange) RequestContentType() []byte { // Folded: field names are case insensitive and HTTP/2 mandates lowercase, so // a proxy translating h2 to h1 sends "content-type", RFC 9110 5.1. - return exch.RequestHeaderV1Raw().GetFold("Content-Type") + return exch.RequestHeader("Content-Type") } // RequestContentLength returns the body length declared by the request's @@ -719,10 +706,10 @@ func (exch *Exchange) ReadMultiparts(dst []MultipartSink, buf []byte, newSink fu } // RequestHeader returns the value of the first request header field matching -// key, or nil if absent. Key matching is case sensitive. +// key, or nil if absent. Matching is not case sensitive. func (exch *Exchange) RequestHeader(key string) []byte { header := exch.RequestHeaderV1Raw() - return header.Get(key) + return header.GetFold(key) } // RequestTarget returns the request-target (URI) of the request line, i.e: @@ -738,7 +725,7 @@ func (exch *Exchange) RequestPath() []byte { } // RequestQuery returns the request's query string as it appears on the wire. -// Iterate it with [httpraw.NextQueryPair]. See [httpraw.HeaderV1.RequestQuery]. +// Iterate it with [httpraw.NextQueryPair]. func (exch *Exchange) RequestQuery() []byte { return exch.RequestHeaderV1Raw().RequestQuery() } @@ -828,11 +815,11 @@ func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]b // RequestMethod returns the request's [Method] enum. func (exch *Exchange) RequestMethod() Method { - return MethodFromBytes(exch.RequestMethodRaw()) + return MethodFromBytes(exch.RequestMethodBytes()) } -// RequestMethod returns the request line's method as a []byte view, i.e: "GET". -func (exch *Exchange) RequestMethodRaw() []byte { +// RequestMethodBytes returns the request line's method as a []byte view, i.e: "GET". +func (exch *Exchange) RequestMethodBytes() []byte { return exch.RequestHeaderV1Raw().Method() } diff --git a/http/httphi/exchange_test.go b/http/httphi/exchange_test.go index ed55202..c859f23 100644 --- a/http/httphi/exchange_test.go +++ b/http/httphi/exchange_test.go @@ -218,7 +218,7 @@ func TestHandleRequestFields(t *testing.T) { var sm MuxSlice route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path. sm.Handle(route, func(ex *Exchange) { - gotMethod = string(ex.RequestMethodRaw()) + gotMethod = string(ex.RequestMethodBytes()) gotURI = string(ex.RequestTarget()) gotHost = string(ex.RequestHeader("Host")) ex.WriteHeader(200) diff --git a/http/httphi/mux.go b/http/httphi/mux.go index bd38a53..f9245e7 100644 --- a/http/httphi/mux.go +++ b/http/httphi/mux.go @@ -252,7 +252,7 @@ func (sm *MuxSlice) Reset(capacity int) { // Every method this package does not name is [MethUnknown], so a request with an // extension method matches a bare-path registration and any registration naming // an extension method, whichever it names. Tell PROPFIND from MKCOL inside the -// handler with [Exchange.RequestMethodRaw]. +// handler with [Exchange.RequestMethodBytes]. func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) { best := -1 bestSpec := 0 @@ -423,9 +423,12 @@ func hasLowerASCII(s string) bool { } // Method is a HTTP request method, parsed by [MethodFrom]. +// Method can only take standardized values and is set to [MethUnknown] for non-standard methods. type Method uint8 const ( + // MethUndefined returned by [MethodFrom] on an empty/missing method. + // Used by [MuxSlice] to denote an unset method kind for a request pattern. MethUndefined Method = iota // undefined MethGet // GET // lol. @@ -438,6 +441,7 @@ const ( MethConnect // CONNECT MethOptions // OPTIONS MethTrace // TRACE + // MethUnknown returned by [MethodFrom] on an non-standard method kind i.e: "get" and "FROBNICATE". MethUnknown // unknown ) @@ -475,9 +479,6 @@ func MethodFrom(meth string) (res Method) { // MethodFromBytes is a [MethodFrom] wrapper with bytes argument instead of string. func MethodFromBytes(meth []byte) (res Method) { - if len(meth) == 0 { - return MethUndefined - } return MethodFrom(b2s(meth)) } diff --git a/http/httphi/mux_test.go b/http/httphi/mux_test.go index 0a55f21..0388041 100644 --- a/http/httphi/mux_test.go +++ b/http/httphi/mux_test.go @@ -183,7 +183,7 @@ func TestExchangePathValueClearedBetweenRequests(t *testing.T) { exch := new(Exchange) exch.Configure(ExchangeConfig{ RawBuf: make([]byte, 2048), RequestBufferLim: 1024, - NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: 4, + NumHeaderKVCap: defaultNumHeaderKVCap, PathValuesBuf: make([]PathValue, 4), }) // First request binds id=42 off a wildcard pattern. @@ -243,7 +243,7 @@ func TestMuxSliceNoStaleBindingsAcrossCandidates(t *testing.T) { exch := new(Exchange) exch.Configure(ExchangeConfig{ RawBuf: make([]byte, 2048), RequestBufferLim: 1024, - NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(), + NumHeaderKVCap: defaultNumHeaderKVCap, PathValuesBuf: make([]PathValue, sm.MaxPathValues()), }) conn := newConn("GET /a/1/b HTTP/1.1\r\nHost: h\r\n\r\n") conn.Hangup() @@ -292,7 +292,7 @@ func TestMuxSliceTrailingSlashPattern(t *testing.T) { exch := new(Exchange) exch.Configure(ExchangeConfig{ RawBuf: make([]byte, 2048), RequestBufferLim: 1024, - NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(), + NumHeaderKVCap: defaultNumHeaderKVCap, PathValuesBuf: make([]PathValue, sm.MaxPathValues()), }) conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n") conn.Hangup() @@ -497,7 +497,7 @@ func TestMuxSliceZeroValueWildcardStillMatches(t *testing.T) { exch := new(Exchange) exch.Configure(ExchangeConfig{ RawBuf: make([]byte, 2048), RequestBufferLim: 1024, - NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(), + NumHeaderKVCap: defaultNumHeaderKVCap, PathValuesBuf: make([]PathValue, sm.MaxPathValues()), }) conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n") conn.Hangup() diff --git a/http/httphi/router.go b/http/httphi/router.go index b86a4d8..d5fd6e0 100644 --- a/http/httphi/router.go +++ b/http/httphi/router.go @@ -58,6 +58,7 @@ type Router struct { mux Mux globbuf []byte + globpath []PathValue exchs []Exchange freeList *Exchange @@ -87,37 +88,19 @@ type RouterConfig struct { // Required [>0] request header key/value pairs to parse before failing with // [StatusRequestHeaderFieldsTooLarge]. RequestNumHeaderKVCap int - // Optional [any] normalization of response header field keys as they are // staged, i.e: "content-type" becomes "Content-Type". NormalizeOutgoingKeys bool - - // Required [non-nil] resolver of each request's method and path to the handler - // serving it. Routes must be registered before Configure, see [Mux.MaxPathValues]. - Mux Mux // Optional [nil disables] sink for failed exchanges. Logger *slog.Logger } -const ( - // minRequestHeaderBuffer is the smallest request buffer [httpraw.Header] - // accepts with buffer growth disabled, which is how exchanges are configured. - minRequestHeaderBuffer = 32 - // minResponseHeaderBuffer is the room [Exchange.FlushHeader] needs for the - // CRLF closing the header block, written even when no field was staged. - minResponseHeaderBuffer = len("\r\n") - // maxExchangeBuffer bounds an exchange's whole buffer: [Exchange] indexes it - // with uint16 offsets, so a larger one would be addressed truncated. - maxExchangeBuffer = math.MaxUint16 -) - // Validate returns a non-nil error if the configuration cannot be used to // configure a [Router]. func (cfg RouterConfig) Validate() error { workerMode := cfg.workerMode() switch { - case cfg.Mux == nil, - !workerMode && cfg.FixedNumGoroutines != -1, + case !workerMode && cfg.FixedNumGoroutines != -1, cfg.RequestNumHeaderKVCap <= 0, cfg.RequestHeaderBufferSize < minRequestHeaderBuffer, cfg.ResponseHeaderMinBufferSize < minResponseHeaderBuffer, @@ -167,7 +150,7 @@ func (r *Router) shutdownLocked() { // Configure may be called on a serving router, but since the exchange buffers // are reused it waits for connections in flight to finish and fails with a // non-nil error rather than reconfigure buffers still being served from. -func (r *Router) Configure(cfg RouterConfig) error { +func (r *Router) Configure(mux Mux, cfg RouterConfig) error { if err := cfg.Validate(); err != nil { return err } @@ -181,9 +164,9 @@ func (r *Router) Configure(cfg RouterConfig) error { r.reqNumHeaderCap = cfg.RequestNumHeaderKVCap r.reqBuf = cfg.RequestHeaderBufferSize r.respBuf = cfg.ResponseHeaderMinBufferSize - r.mux = cfg.Mux + r.mux = mux r.log = cfg.Logger - maxPathValues := cfg.Mux.MaxPathValues() + maxPathValues := mux.MaxPathValues() if maxPathValues < 0 { return errors.New("Mux paths must be registered before configuring Router") } @@ -211,20 +194,20 @@ func (r *Router) Configure(cfg RouterConfig) error { r.exchs = r.exchs[:numgoro] rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize internal.SliceReuse(&r.globbuf, numgoro*rawBuflen) + internal.SliceReuse(&r.globpath, numgoro*maxPathValues) for i := range numgoro { - // TODO exchange buffer alloc goff := i * rawBuflen - // r.globbuf[goff:goff+rawBuflen], cfg.RequestHeaderBufferSize, cfg.RequestNumHeaderCap, cfg.NormalizeOutgoingKeys + poff := i * maxPathValues r.exchs[i].Configure(ExchangeConfig{ RawBuf: r.globbuf[goff : goff+rawBuflen], RequestBufferLim: cfg.RequestHeaderBufferSize, NumHeaderKVCap: cfg.RequestNumHeaderKVCap, NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys, NoRequestBufferGrowth: true, // Hard memory limit. - MaxPathValues: maxPathValues, + PathValuesBuf: r.globpath[poff : poff+maxPathValues], }) - go r.goroWorker(gen, jobqueue, cfg.Mux) + go r.goroWorker(gen, jobqueue, mux) } r.pendingConns = jobqueue r.numGoro = numgoro @@ -388,7 +371,7 @@ func (r *Router) getExchLocked(conn conn) (exch *Exchange) { NumHeaderKVCap: r.reqNumHeaderCap, NormalizeOutgoingKeys: r.normalizeKeys, NoRequestBufferGrowth: true, - MaxPathValues: r.maxPathValues, + PathValuesBuf: make([]PathValue, r.maxPathValues), }) exch.Acquire(conn) // Fresh exchange, CAS cannot fail. return exch diff --git a/http/httphi/router_test.go b/http/httphi/router_test.go index d688774..8d7901e 100644 --- a/http/httphi/router_test.go +++ b/http/httphi/router_test.go @@ -150,9 +150,8 @@ func (r *rwconn) ViewWritten() string { var _ Mux = (*MuxSlice)(nil) func configSynchronousRouter(t *testing.T, router *Router, bufferSize int, mux Mux) { - err := router.Configure(RouterConfig{ + err := router.Configure(mux, RouterConfig{ FixedNumGoroutines: -1, - Mux: mux, RequestHeaderBufferSize: bufferSize, RequestNumHeaderKVCap: 16, ResponseHeaderMinBufferSize: bufferSize, @@ -198,7 +197,7 @@ func TestRouterRequestVisibleToHandler(t *testing.T) { var gotMethod, gotURI, gotHost string var gotMethodEnum Method sm.Handle("GET /index.html", func(ex *Exchange) { - gotMethod = string(ex.RequestMethodRaw()) + gotMethod = string(ex.RequestMethodBytes()) gotMethodEnum = ex.RequestMethod() gotURI = string(ex.RequestTarget()) gotHost = string(ex.RequestHeader("Host")) @@ -388,9 +387,8 @@ func TestRouterHandleAfterTeardown(t *testing.T) { router Router ) sm.Handle("GET /", staticPage(t, "ok")) - err := router.Configure(RouterConfig{ + err := router.Configure(&sm, RouterConfig{ FixedNumGoroutines: 2, - Mux: &sm, RequestHeaderBufferSize: 512, RequestNumHeaderKVCap: 16, ResponseHeaderMinBufferSize: 512, @@ -422,7 +420,6 @@ func TestRouterTeardownReleasesQueuedConns(t *testing.T) { sm.Handle("GET /", staticPage(t, "ok")) cfg := RouterConfig{ FixedNumGoroutines: numGoro, - Mux: &sm, RequestHeaderBufferSize: 512, RequestNumHeaderKVCap: 16, ResponseHeaderMinBufferSize: 512, @@ -436,7 +433,7 @@ func TestRouterTeardownReleasesQueuedConns(t *testing.T) { // generation drops its connections, but it must not outlive it. var err error for range 100 { - if err = router.Configure(cfg); err == nil { + if err = router.Configure(&sm, cfg); err == nil { break } time.Sleep(time.Millisecond) @@ -468,12 +465,11 @@ func TestRouterConfigureDuringWorkerHandle(t *testing.T) { sm.Handle("GET /", staticPage(t, "ok")) cfg := RouterConfig{ FixedNumGoroutines: 2, - Mux: &sm, RequestHeaderBufferSize: 512, RequestNumHeaderKVCap: 16, ResponseHeaderMinBufferSize: 512, } - if err := router.Configure(cfg); err != nil { + if err := router.Configure(&sm, cfg); err != nil { t.Fatal(err) } defer router.Shutdown() @@ -495,7 +491,7 @@ func TestRouterConfigureDuringWorkerHandle(t *testing.T) { for range 20 { // errBusyExchanges is legitimate backpressure: the previous // generation was still serving when the buffers were needed. - if err := router.Configure(cfg); err != nil && err != errBusyExchanges { + if err := router.Configure(&sm, cfg); err != nil && err != errBusyExchanges { t.Error(err) return } diff --git a/http/httphi/routermem.go b/http/httphi/routermem.go new file mode 100644 index 0000000..24ac14b --- /dev/null +++ b/http/httphi/routermem.go @@ -0,0 +1,122 @@ +package httphi + +import ( + "math" + "unsafe" + + "github.com/soypat/lneto/http/httpraw" +) + +const ( + // minRequestHeaderBuffer is the smallest request buffer [httpraw.Header] + // accepts with buffer growth disabled, which is how exchanges are configured. + minRequestHeaderBuffer = 32 + // minResponseHeaderBuffer is the room [Exchange.FlushHeader] needs for the + // CRLF closing the header block, written even when no field was staged. + minResponseHeaderBuffer = len("\r\n") + // maxExchangeBuffer bounds an exchange's whole buffer: [Exchange] indexes it + // with uint16 offsets, so a larger one would be addressed truncated. + maxExchangeBuffer = math.MaxUint16 + + // sizeofExchange is the fixed cost of an exchange, which a Router pays per + // connection it can serve concurrently on top of the buffers it hands it. + // It dwarfs a small request buffer, so budgets must account for it. + sizeofExchange = int(unsafe.Sizeof(Exchange{})) + // sizeofPathValue is the per-wildcard cost of the path value table. + sizeofPathValue = int(unsafe.Sizeof(PathValue{})) + // sizeofJob is an exchange's slot in the queue connections wait on for a + // worker goroutine, sized to the goroutine count in worker mode. + sizeofJob = int(unsafe.Sizeof(job{})) + + // bytesPerHeaderField is the request buffer [DefaultRouterConfig] budgets per + // parseable header field. Real fields run a little longer than this + // ("Accept-Encoding: gzip, deflate, br\r\n" is 35 bytes), so a request fills + // the buffer before it exhausts the field table, which is the cheaper of the + // two limits to hit: growing the table costs [httpraw.SizeKV] per field on top + // of the bytes the field already occupies. + bytesPerHeaderField = 32 + // defaultResponseHeaderBuffer is the response header room + // [DefaultRouterConfig] reserves when the budget can afford it: enough for a + // Content-Type, a Content-Length and a Connection field with room to spare. + // It does not scale with the request buffer because what a response header + // costs depends on the fields a handler stages, not on the request's size. + defaultResponseHeaderBuffer = 128 +) + +// MemoryUsagePerConnection returns the heap bytes a [Router] configured with cfg +// reserves for each connection it can serve concurrently, maxPathValues being +// the [Mux.MaxPathValues] of the mux it is configured with. Goroutine stacks are +// not counted: those are the runtime's to size, not the router's. +// +// In worker mode this is exact and fixed, so a router's whole heap footprint is +// this times FixedNumGoroutines, plus the runtime's own header for the job +// queue. With FixedNumGoroutines -1 the router allocates one of these per +// connection in flight instead, so the total grows with peak concurrency. +// +// It is the inverse of [DefaultRouterConfig] and useful to check a hand written +// configuration against a memory budget. +func (cfg RouterConfig) MemoryUsagePerConnection(maxPathValues int) int { + if maxPathValues < 0 { + maxPathValues = 0 // Mux with no routes registered yet, see [Mux.MaxPathValues]. + } + n := sizeofExchange + // Exchange itself, an element of the router's exchange store. + cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize + // Its window into the raw buffer. + cfg.RequestNumHeaderKVCap*httpraw.SizeKV + // The request header's field table. + maxPathValues*sizeofPathValue // Its window into the path value store. + if cfg.workerMode() { + n += sizeofJob // Slot in the queue connections wait on for a worker. + } + return n +} + +// DefaultRouterConfig is a general purpose configuration creator +// for small, medium, large, performant or embedded projects. +// +// Generalness is achieved with parameters that let the Configuration +// determine allocation buffer sizes based on typical usage for that +// number of goroutines and heap allocation on a per-connection basis. +func DefaultRouterConfig(numGoroutines, memoryPerConnectionBytes, maxPathValues int) RouterConfig { + // The budget is spent on the request header buffer first, since that is what + // decides which requests are answered at all, then on a field table sized to + // match it and a small response header reserve. A budget too small to fund the + // minimum viable exchange yields the minimum instead, so the returned config is + // always one [Router.Configure] accepts but may exceed a budget under roughly + // sizeofExchange + 200 bytes. Check it with MemoryUsagePerConnection when the + // bound has to hold. + if numGoroutines <= 0 { + numGoroutines = -1 // Unbounded mode, the only non-positive value Validate accepts. + } + // Everything the exchange costs before any buffer is sized: subtract it first + // so the buffers below divide up what is actually left to spend. + fixed := sizeofExchange + maxPathValues*sizeofPathValue + if numGoroutines > 0 { + fixed += sizeofJob + } + // A budget past what the buffers may grow to is only spendable up to the cap + // below, so clamp before the products: on a 32 bit target an unclamped + // multiply would overflow and wrap a generous budget into a tiny buffer. + const maxSpendable = (maxExchangeBuffer + defaultResponseHeaderBuffer) * + (bytesPerHeaderField + httpraw.SizeKV) / bytesPerHeaderField + avail := min(memoryPerConnectionBytes-fixed, maxSpendable) + + // The response reserve is a floor rather than a share of the budget, but a + // budget this small cannot afford the full one without starving the request. + respBuf := min(defaultResponseHeaderBuffer, avail/4) + + // Solve avail-respBuf = reqBuf + reqBuf/bytesPerHeaderField*httpraw.SizeKV for + // reqBuf, the field table growing with the buffer it parses. + reqBuf := (avail - respBuf) * bytesPerHeaderField / (bytesPerHeaderField + httpraw.SizeKV) + + // Clamp to what [RouterConfig.Validate] accepts. Truncating division above + // keeps the result under budget; these floors are what can push it over. + respBuf = max(respBuf, minResponseHeaderBuffer) + reqBuf = max(reqBuf, minRequestHeaderBuffer) + reqBuf = min(reqBuf, maxExchangeBuffer-respBuf) + return RouterConfig{ + FixedNumGoroutines: numGoroutines, + RequestHeaderBufferSize: reqBuf, + ResponseHeaderMinBufferSize: respBuf, + RequestNumHeaderKVCap: max(reqBuf/bytesPerHeaderField, 1), + NormalizeOutgoingKeys: false, + } +} diff --git a/http/httphi/routermem_test.go b/http/httphi/routermem_test.go new file mode 100644 index 0000000..0342fe3 --- /dev/null +++ b/http/httphi/routermem_test.go @@ -0,0 +1,231 @@ +package httphi + +import ( + "testing" + + "github.com/soypat/lneto/http/httpraw" +) + +// budgetMux exposes a settable path value count so budget tests can sweep it +// without registering patterns that bind that many wildcards. +type budgetMux struct { + MuxSlice + maxPathValues int +} + +func (m *budgetMux) MaxPathValues() int { return m.maxPathValues } + +func newBudgetMux(maxPathValues int) *budgetMux { + mux := &budgetMux{maxPathValues: maxPathValues} + mux.Handle("GET /", func(*Exchange) {}) + return mux +} + +// TestDefaultRouterConfigHonorsBudget sweeps budgets and path value counts and +// checks the returned configuration both fits its budget and configures a +// router. The floor is documented: below it the minimum viable exchange comes +// back instead, which is the only case allowed to exceed the budget. +func TestDefaultRouterConfigHonorsBudget(t *testing.T) { + minCfg := RouterConfig{ + FixedNumGoroutines: 1, + RequestHeaderBufferSize: minRequestHeaderBuffer, + ResponseHeaderMinBufferSize: minResponseHeaderBuffer, + RequestNumHeaderKVCap: 1, + } + for _, numGoro := range []int{-1, 1, 4} { + for _, maxPathValues := range []int{0, 1, 4, 32} { + floor := minCfg.MemoryUsagePerConnection(maxPathValues) + mux := newBudgetMux(maxPathValues) + for _, budget := range []int{0, 1, 64, 256, 512, 1024, 4096, 65536, 1 << 20} { + cfg := DefaultRouterConfig(numGoro, budget, maxPathValues) + if err := cfg.Validate(); err != nil { + t.Fatalf("goro=%d pathvals=%d budget=%d: %s", numGoro, maxPathValues, budget, err) + } + got := cfg.MemoryUsagePerConnection(maxPathValues) + if got > budget && budget >= floor { + t.Errorf("goro=%d pathvals=%d budget=%d: uses %d bytes, over budget", + numGoro, maxPathValues, budget, got) + } + var router Router + if err := router.Configure(mux, cfg); err != nil { + t.Fatalf("goro=%d pathvals=%d budget=%d: %s", numGoro, maxPathValues, budget, err) + } + router.Shutdown() + } + } + } +} + +// TestDefaultRouterConfigSpendsBudget guards the other direction: a config that +// fits but leaves most of the budget unspent is as wrong as one that overruns, +// since the memory is reserved either way. +func TestDefaultRouterConfigSpendsBudget(t *testing.T) { + const maxPathValues = 2 + for _, budget := range []int{1024, 2048, 4096, 16384} { + cfg := DefaultRouterConfig(4, budget, maxPathValues) + used := cfg.MemoryUsagePerConnection(maxPathValues) + if pct := used * 100 / budget; pct < 95 { + t.Errorf("budget=%d: spends only %d bytes (%d%%)", budget, used, pct) + } + } +} + +// TestExchangeMemoryTerms pins each term of MemoryUsagePerConnection to the +// allocation it stands for, so a layout change downstream fails here rather than +// silently letting a router overrun its budget. +func TestExchangeMemoryTerms(t *testing.T) { + const maxPathValues = 4 + cfg := RouterConfig{ + FixedNumGoroutines: 2, + RequestHeaderBufferSize: 512, + ResponseHeaderMinBufferSize: 128, + RequestNumHeaderKVCap: 16, + } + want := sizeofExchange + 512 + 128 + 16*httpraw.SizeKV + maxPathValues*sizeofPathValue + sizeofJob + if got := cfg.MemoryUsagePerConnection(maxPathValues); got != want { + t.Errorf("worker mode: got %d want %d", got, want) + } + // Unbounded mode has no job queue to reserve a slot in. + cfg.FixedNumGoroutines = -1 + if got := cfg.MemoryUsagePerConnection(maxPathValues); got != want-sizeofJob { + t.Errorf("unbounded mode: got %d want %d", got, want-sizeofJob) + } + // A mux with no routes registered reports -1, which must not subtract memory. + if got := cfg.MemoryUsagePerConnection(-1); got != cfg.MemoryUsagePerConnection(0) { + t.Errorf("unregistered mux: got %d want %d", got, cfg.MemoryUsagePerConnection(0)) + } +} + +// TestRouterSharesExchangeStores checks the invariant the memory accounting +// rests on: every exchange's buffer and path values are windows into the two +// stores the router allocates, non-overlapping and exactly the configured size. +// Measuring allocations would only observe this indirectly. +func TestRouterSharesExchangeStores(t *testing.T) { + const numGoro = 8 + const maxPathValues = 3 + mux := newBudgetMux(maxPathValues) + cfg := DefaultRouterConfig(numGoro, 1024, maxPathValues) + var router Router + if err := router.Configure(mux, cfg); err != nil { + t.Fatal(err) + } + defer router.Shutdown() + + wantRaw := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize + if len(router.exchs) != numGoro { + t.Fatalf("got %d exchanges, want %d", len(router.exchs), numGoro) + } + if cap(router.globbuf) < numGoro*wantRaw { + t.Errorf("raw store holds %d bytes, want %d", cap(router.globbuf), numGoro*wantRaw) + } + if cap(router.globpath) < numGoro*maxPathValues { + t.Errorf("path store holds %d values, want %d", cap(router.globpath), numGoro*maxPathValues) + } + rawSeen := make(map[*byte]int, numGoro*wantRaw) + pathSeen := make(map[*PathValue]int, numGoro*maxPathValues) + for i := range router.exchs { + exch := &router.exchs[i] + if len(exch.rawbuf) != wantRaw { + t.Errorf("exchange %d: raw buffer is %d bytes, want %d", i, len(exch.rawbuf), wantRaw) + } + if len(exch.pathValues) != maxPathValues { + t.Errorf("exchange %d: %d path values, want %d", i, len(exch.pathValues), maxPathValues) + } + // Every byte must come from the shared store and belong to this exchange + // alone: an exchange allocating its own, or two sharing a window, would + // make the per-connection accounting a fiction. + for j := range exch.rawbuf { + p := &exch.rawbuf[j] + if owner, dup := rawSeen[p]; dup { + t.Fatalf("exchanges %d and %d share raw buffer byte %d", owner, i, j) + } + rawSeen[p] = i + } + for j := range exch.pathValues { + p := &exch.pathValues[j] + if owner, dup := pathSeen[p]; dup { + t.Fatalf("exchanges %d and %d share path value %d", owner, i, j) + } + pathSeen[p] = i + } + } + if len(rawSeen) != numGoro*wantRaw { + t.Errorf("exchanges cover %d raw bytes, want %d", len(rawSeen), numGoro*wantRaw) + } +} + +// TestExchangeConfigureIsAllocationFree checks an exchange handed all of its +// memory allocates none of its own, which is what lets a router carve every +// exchange out of its two stores. +func TestExchangeConfigureIsAllocationFree(t *testing.T) { + cfg := ExchangeConfig{ + RawBuf: make([]byte, 640), + RequestBufferLim: 512, + NumHeaderKVCap: 16, + NoRequestBufferGrowth: true, + PathValuesBuf: make([]PathValue, 4), + } + var exch Exchange + exch.Configure(cfg) // Field table allocates once, then settles. + allocs := testing.AllocsPerRun(100, func() { + exch.Configure(cfg) + }) + if allocs != 0 { + t.Errorf("Exchange.Configure allocates %v times, want 0", allocs) + } +} + +// TestMemoryUsagePerConnectionMatchesHeap checks the accounting against the heap +// a router actually takes, which is what makes the number worth budgeting +// against. +// +// It measures two budgets and compares the difference rather than either +// absolute figure. A router's heap carries costs the accounting does not claim +// and should not: size class rounding, the job queue's runtime header and the +// runtime's per-goroutine bookkeeping. Those are identical at both budgets, so +// subtracting cancels them and leaves only the buffers, whose growth is exactly +// what MemoryUsagePerConnection predicts. Goroutine stacks never enter into it, +// the runtime accounting them separately from the heap measured here. +func TestMemoryUsagePerConnectionMatchesHeap(t *testing.T) { + if testing.Short() { + t.Skip("measures heap over many Configure iterations") + } + const numGoro = 64 + const maxPathValues = 3 + mux := newBudgetMux(maxPathValues) + measure := func(budget int) (accounted, heap int) { + cfg := DefaultRouterConfig(numGoro, budget, maxPathValues) + res := testing.Benchmark(func(b *testing.B) { + b.ReportAllocs() + for range b.N { + var router Router + if err := router.Configure(mux, cfg); err != nil { + b.Fatal(err) + } + router.Shutdown() + } + }) + return numGoro * cfg.MemoryUsagePerConnection(maxPathValues), int(res.AllocedBytesPerOp()) + } + lowAcct, lowHeap := measure(2048) + highAcct, highHeap := measure(16384) + wantGrowth := highAcct - lowAcct + gotGrowth := highHeap - lowHeap + t.Logf("accounted %d->%d (+%d), heap %d->%d (+%d)", + lowAcct, highAcct, wantGrowth, lowHeap, highHeap, gotGrowth) + + // What remains after cancelling is buffer growth, which the accounting covers + // term for term. Only size class rounding on the grown buffers is left over. + const tolerancePercent = 2 + if diff := abs(gotGrowth - wantGrowth); diff*100 > wantGrowth*tolerancePercent { + t.Errorf("budget growth accounted %d bytes, heap grew %d (%+d)", + wantGrowth, gotGrowth, gotGrowth-wantGrowth) + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/http/httphi/status.go b/http/httphi/status.go index a45ded6..ca37e7c 100644 --- a/http/httphi/status.go +++ b/http/httphi/status.go @@ -3,7 +3,7 @@ package httphi // StatusText returns a text for the HTTP status code. It returns the empty // string if the code is unknown. func StatusText(code int) string { - switch status(code) { + switch code { case StatusContinue: return "Continue" case StatusSwitchingProtocols: @@ -133,10 +133,6 @@ func StatusText(code int) string { } } -const () - -type status int - // HTTP status codes as registered with IANA. // See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml const ( diff --git a/http/httpraw/kvbuffer.go b/http/httpraw/kvbuffer.go index 0072db6..7fd3005 100644 --- a/http/httpraw/kvbuffer.go +++ b/http/httpraw/kvbuffer.go @@ -462,6 +462,12 @@ type pairKV struct { value view // value start >0 means value is present. } +// SizeKV is the heap cost of a single key/value field slot, as reserved by the +// numHeaderCapacity argument to [HeaderV1.Reset] and by [Form.Reset]. Callers +// budgeting a fixed memory pool up front, such as a Router sizing its +// exchanges, multiply it by the pair capacity to account the field table. +const SizeKV = int(unsafe.Sizeof(pairKV{})) + // isValid is for stores parsed in place, where offset 0 is the first key so // only length can signal presence. Empty keys are valid: see valueless cookies. func (pair pairKV) isValid() bool {