Mux.MaxPathValues and other improvements

This commit is contained in:
Patricio Whittingslow
2026-07-29 22:26:38 -03:00
parent 64c3658d76
commit a68e73be59
8 changed files with 73 additions and 43 deletions
-1
View File
@@ -28,7 +28,6 @@ err := router.Configure(httphi.RouterConfig{
RequestHeaderBufferSize: 1024, RequestHeaderBufferSize: 1024,
ResponseHeaderMinBufferSize: 32, // Shares the request buffer. ResponseHeaderMinBufferSize: 32, // Shares the request buffer.
RequestNumHeaderKVCap: 32, RequestNumHeaderKVCap: 32,
Backoff: func(uint) time.Duration { return time.Millisecond },
Mux: &mux, Mux: &mux,
}) })
if err != nil { if err != nil {
+17 -6
View File
@@ -35,7 +35,7 @@ type Exchange struct {
respHeaderOff uint16 respHeaderOff uint16
respHeaderLen uint16 respHeaderLen uint16
reqHdr httpraw.Header reqHdr httpraw.Header
pathValues []pathValue pathValues []PathValue
hijacked bool hijacked bool
rw conn rw conn
@@ -253,11 +253,18 @@ func (exch *Exchange) StageStatus(code int) {
// WriteHeader sends the status line for code along with the staged header // WriteHeader sends the status line for code along with the staged header
// fields. Only the first call reaches the wire, as in http.ResponseWriter. // fields. Only the first call reaches the wire, as in http.ResponseWriter.
func (exch *Exchange) WriteHeader(code int) { func (exch *Exchange) WriteHeader(code int) (n int, err error) {
if !exch.headerWritten { if !exch.headerWritten {
exch.StageStatus(code) exch.StageStatus(code)
exch.FlushHeader() n, err = exch.FlushHeader()
} }
return n, err
}
// ResponseError returns any error encountered during staging of headers or during writing of response.
// Provides an ergonomic way of checking if one ran out of buffer space after staging all headers with [Exchange.StageHeader].
func (exch *Exchange) ResponseError() error {
return exch.respErr
} }
// FlushHeader writes the status line and staged header fields to the connection // FlushHeader writes the status line and staged header fields to the connection
@@ -709,9 +716,13 @@ func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]b
return dst[:base+n], nil return dst[:base+n], nil
} }
// RequestMethod returns the request line's method, i.e: "GET". See // RequestMethod returns the request's [Method] enum.
// [MethodFromBytes] to compare it against a [Method]. func (exch *Exchange) RequestMethod() Method {
func (exch *Exchange) RequestMethod() []byte { return MethodFromBytes(exch.RequestMethodRaw())
}
// RequestMethod returns the request line's method as a []byte view, i.e: "GET".
func (exch *Exchange) RequestMethodRaw() []byte {
return exch.RequestHeaderRaw().Method() return exch.RequestHeaderRaw().Method()
} }
+1 -1
View File
@@ -214,7 +214,7 @@ func TestHandleRequestFields(t *testing.T) {
var sm MuxSlice var sm MuxSlice
route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path. route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path.
sm.Handle(route, func(ex *Exchange) { sm.Handle(route, func(ex *Exchange) {
gotMethod = string(ex.RequestMethod()) gotMethod = string(ex.RequestMethodRaw())
gotURI = string(ex.RequestTarget()) gotURI = string(ex.RequestTarget())
gotHost = string(ex.RequestHeader("Host")) gotHost = string(ex.RequestHeader("Host"))
ex.WriteHeader(200) ex.WriteHeader(200)
+31 -19
View File
@@ -101,22 +101,13 @@ type Mux interface {
// LookupHandler matches the requestPath and method to a handler and returns it and the // LookupHandler matches the requestPath and method to a handler and returns it and the
// pattern it matched. dstPathVals are set to non-zero values by Mux and can later be accessed by [Exchange.PathValue] // pattern it matched. dstPathVals are set to non-zero values by Mux and can later be accessed by [Exchange.PathValue]
// requestPath is a buffer owned by the [Exchange] usually and should not be held after LookupHandler returns. // requestPath is a buffer owned by the [Exchange] usually and should not be held after LookupHandler returns.
LookupHandler(get Method, requestPath []byte, dstPathVals []pathValue) (matchedPattern string, handler HandlerFunc) LookupHandler(get Method, requestPath []byte, dstPathVals []PathValue) (matchedPattern string, handler HandlerFunc)
// MaxPathValues specifies the required size of dstPathVals in a call to [Mux.LookupHandler].
MaxPathValues() int
} }
// MuxSlice is a [Mux] backed by a slice of registered endpoints, matched by // PathValue used to implement [Mux] interface. Stores http.Request.PathValue-like values.
// exact path. Lookup is linear in the number of registrations. type PathValue struct {
type MuxSlice struct {
// TODO: binary search worth it?
_handlers []struct {
method Method
path string
handler HandlerFunc
setPathVal bool
}
}
type pathValue struct {
Key string // owned by mux. Key string // owned by mux.
Value []byte // points to raw exchange buffer. Value []byte // points to raw exchange buffer.
} }
@@ -133,7 +124,7 @@ var pathSeparator = []byte{'/'}
// Unlike ServeMux, segments are compared and bound raw, so "/users/{id}" binds // Unlike ServeMux, segments are compared and bound raw, so "/users/{id}" binds
// "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias // "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias
// requestPath rather than copy it. // requestPath rather than copy it.
func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) { func SetPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) {
if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' { if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' {
return false, false return false, false
} }
@@ -160,7 +151,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte)
if n >= len(dstPathVals) { if n >= len(dstPathVals) {
return false, true return false, true
} }
dstPathVals[n] = pathValue{Key: name, Value: requestPath} dstPathVals[n] = PathValue{Key: name, Value: requestPath}
n++ n++
} }
return true, false return true, false
@@ -172,7 +163,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte)
if n >= len(dstPathVals) { if n >= len(dstPathVals) {
return false, true return false, true
} }
dstPathVals[n] = pathValue{Key: name, Value: reqSeg} dstPathVals[n] = PathValue{Key: name, Value: reqSeg}
n++ n++
default: default:
@@ -205,6 +196,18 @@ func pathWildcard(segment string) (name string, isMulti, ok bool) {
return name, false, true return name, false, true
} }
// MuxSlice is a [Mux] implementation backed by a slice of registered endpoints, matched by
// exact path. Lookup is linear in the number of registrations.
type MuxSlice struct {
// TODO: binary search worth it?
_handlers []struct {
method Method
path string
handler HandlerFunc
pathVals int
}
}
// Reset discards all registered handlers, reusing the backing array and growing // Reset discards all registered handlers, reusing the backing array and growing
// it to fit capacity registrations. // it to fit capacity registrations.
func (sm *MuxSlice) Reset(capacity int) { func (sm *MuxSlice) Reset(capacity int) {
@@ -213,13 +216,13 @@ func (sm *MuxSlice) Reset(capacity int) {
// LookupHandler returns the handler registered for request path, or nil if none matches. // LookupHandler returns the handler registered for request path, or nil if none matches.
// The first registration matching both method and uri wins. // The first registration matching both method and uri wins.
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []pathValue) (matched string, _ HandlerFunc) { func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) {
for _, endpoint := range sm._handlers { for _, endpoint := range sm._handlers {
if endpoint.method != MethUndefined && endpoint.method != method { if endpoint.method != MethUndefined && endpoint.method != method {
continue continue
} }
// Method matches. // Method matches.
if endpoint.setPathVal { if endpoint.pathVals > 0 {
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok { if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
return endpoint.path, endpoint.handler return endpoint.path, endpoint.handler
} }
@@ -230,6 +233,14 @@ func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []path
return "", nil return "", nil
} }
// MaxPathValues returns the maximum number of path values any endpoint could have.
func (sm *MuxSlice) MaxPathValues() (maxPathValues int) {
for _, endpoint := range sm._handlers {
maxPathValues = max(maxPathValues, endpoint.pathVals)
}
return maxPathValues
}
// Handle registers handler for reg, either a bare path matching any method or a // Handle registers handler for reg, either a bare path matching any method or a
// method and path separated by a space, i.e: "/health" or "GET /health". // method and path separated by a space, i.e: "/health" or "GET /health".
// Handle does not check for duplicate registrations: the first one added wins. // Handle does not check for duplicate registrations: the first one added wins.
@@ -242,6 +253,7 @@ func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
} else { } else {
url = methodOrURL url = methodOrURL
} }
v.pathVals = strings.Count(optMethodAndPath, "{")
v.method = method v.method = method
v.path = url v.path = url
v.handler = handler v.handler = handler
+12 -8
View File
@@ -54,7 +54,7 @@ func TestSetPathValues(t *testing.T) {
{pattern: "/a/{x}/b", path: "/a//b", match: false}, {pattern: "/a/{x}/b", path: "/a//b", match: false},
} { } {
t.Run(test.pattern+"__"+test.path, func(t *testing.T) { t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
vals := make([]pathValue, 8) vals := make([]PathValue, 8)
match, tooShort := SetPathValues(vals, test.pattern, []byte(test.path)) match, tooShort := SetPathValues(vals, test.pattern, []byte(test.path))
if tooShort { if tooShort {
t.Fatal("8 slots must be enough for these patterns") t.Fatal("8 slots must be enough for these patterns")
@@ -76,7 +76,7 @@ func TestSetPathValues(t *testing.T) {
// exchange owns that memory and a copy would allocate per request. // exchange owns that memory and a copy would allocate per request.
func TestSetPathValuesAliasesRequestBuffer(t *testing.T) { func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
path := []byte("/users/42/edit") path := []byte("/users/42/edit")
vals := make([]pathValue, 4) vals := make([]PathValue, 4)
match, _ := SetPathValues(vals, "/users/{id}/edit", path) match, _ := SetPathValues(vals, "/users/{id}/edit", path)
if !match { if !match {
t.Fatal("want match") t.Fatal("want match")
@@ -94,7 +94,7 @@ func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
// A destination too small to hold every wildcard must say so rather than bind a // A destination too small to hold every wildcard must say so rather than bind a
// partial set or write out of range. // partial set or write out of range.
func TestSetPathValuesSliceTooShort(t *testing.T) { func TestSetPathValuesSliceTooShort(t *testing.T) {
match, tooShort := SetPathValues(make([]pathValue, 1), "/{a}/{b}", []byte("/x/y")) match, tooShort := SetPathValues(make([]PathValue, 1), "/{a}/{b}", []byte("/x/y"))
if !tooShort { if !tooShort {
t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot") t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot")
} }
@@ -112,11 +112,11 @@ func TestSetPathValuesSliceTooShort(t *testing.T) {
// segment, so "/users/x%2Fy" binds id="x/y" there and id="x%2Fy" here. Matching // segment, so "/users/x%2Fy" binds id="x/y" there and id="x%2Fy" here. Matching
// agrees either way; only the bound bytes differ. // agrees either way; only the bound bytes differ.
func TestSetPathValuesEscaping(t *testing.T) { func TestSetPathValuesEscaping(t *testing.T) {
vals := make([]pathValue, 4) vals := make([]PathValue, 4)
if match, _ := SetPathValues(vals, "/a%2Fb/{x}", []byte("/a%2Fb/v")); !match { if match, _ := SetPathValues(vals, "/a%2Fb/{x}", []byte("/a%2Fb/v")); !match {
t.Error("want literal escape in pattern to match the same bytes in path") t.Error("want literal escape in pattern to match the same bytes in path")
} }
vals = make([]pathValue, 4) vals = make([]PathValue, 4)
match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy")) match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy"))
if !match { if !match {
t.Fatal("want match") t.Fatal("want match")
@@ -128,7 +128,7 @@ func TestSetPathValuesEscaping(t *testing.T) {
// renderPathValues joins the bound pairs for comparison, stopping at the first // renderPathValues joins the bound pairs for comparison, stopping at the first
// unused slot. // unused slot.
func renderPathValues(vals []pathValue) string { func renderPathValues(vals []PathValue) string {
var sb strings.Builder var sb strings.Builder
for _, v := range vals { for _, v := range vals {
if v.Key == "" { if v.Key == "" {
@@ -147,7 +147,7 @@ func renderPathValues(vals []pathValue) string {
// Matching a request must not allocate: keys alias the mux's pattern and values // Matching a request must not allocate: keys alias the mux's pattern and values
// alias the request buffer, so nothing is copied per request. // alias the request buffer, so nothing is copied per request.
func TestSetPathValuesNoAlloc(t *testing.T) { func TestSetPathValuesNoAlloc(t *testing.T) {
vals := make([]pathValue, 8) vals := make([]PathValue, 8)
path := []byte("/b/bk/o/a/b/c") path := []byte("/b/bk/o/a/b/c")
allocs := testing.AllocsPerRun(100, func() { allocs := testing.AllocsPerRun(100, func() {
SetPathValues(vals, "/b/{bucket}/o/{obj...}", path) SetPathValues(vals, "/b/{bucket}/o/{obj...}", path)
@@ -164,7 +164,11 @@ type pathValueMux struct {
handler HandlerFunc handler HandlerFunc
} }
func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []pathValue) (string, HandlerFunc) { func (m *pathValueMux) MaxPathValues() int {
return -1
}
func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []PathValue) (string, HandlerFunc) {
if ok, _ := SetPathValues(dst, m.pattern, path); ok { if ok, _ := SetPathValues(dst, m.pattern, path); ok {
return m.pattern, m.handler return m.pattern, m.handler
} }
+3 -4
View File
@@ -84,8 +84,6 @@ type RouterConfig struct {
ResponseHeaderMinBufferSize int ResponseHeaderMinBufferSize int
// Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge]. // Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge].
RequestNumHeaderKVCap int RequestNumHeaderKVCap int
// Sets maximum number of PathValue pairs that can be set on an exchange. Accessed via [Exchange.PathValue].
MaxPathValues int
// NormalizeOutgoingKeys normalizes response header field keys as they are // NormalizeOutgoingKeys normalizes response header field keys as they are
// staged, i.e: "content-type" becomes "Content-Type". // staged, i.e: "content-type" becomes "Content-Type".
@@ -186,7 +184,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
r.respBuf = cfg.ResponseHeaderMinBufferSize r.respBuf = cfg.ResponseHeaderMinBufferSize
r.mux = cfg.Mux r.mux = cfg.Mux
r.log = cfg.Logger r.log = cfg.Logger
r.maxPathValues = cfg.MaxPathValues r.maxPathValues = cfg.Mux.MaxPathValues()
r.normalizeKeys = cfg.NormalizeOutgoingKeys r.normalizeKeys = cfg.NormalizeOutgoingKeys
// Freelist entries were sized by the outgoing configuration: recycling one // Freelist entries were sized by the outgoing configuration: recycling one
// would serve a request with buffer limits cfg never asked for. // would serve a request with buffer limits cfg never asked for.
@@ -210,6 +208,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
r.exchs = r.exchs[:numgoro] r.exchs = r.exchs[:numgoro]
rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
internal.SliceReuse(&r.globbuf, numgoro*rawBuflen) internal.SliceReuse(&r.globbuf, numgoro*rawBuflen)
maxPathValues := cfg.Mux.MaxPathValues()
for i := range numgoro { for i := range numgoro {
// TODO exchange buffer alloc // TODO exchange buffer alloc
goff := i * rawBuflen goff := i * rawBuflen
@@ -220,7 +219,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
NumHeaderKVCap: cfg.RequestNumHeaderKVCap, NumHeaderKVCap: cfg.RequestNumHeaderKVCap,
NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys, NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys,
NoRequestBufferGrowth: true, // Hard memory limit. NoRequestBufferGrowth: true, // Hard memory limit.
MaxPathValues: cfg.MaxPathValues, MaxPathValues: maxPathValues,
}) })
go r.goroWorker(gen, jobqueue, cfg.Mux) go r.goroWorker(gen, jobqueue, cfg.Mux)
} }
+6 -1
View File
@@ -196,8 +196,10 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
router Router router Router
) )
var gotMethod, gotURI, gotHost string var gotMethod, gotURI, gotHost string
var gotMethodEnum Method
sm.Handle("GET /index.html", func(ex *Exchange) { sm.Handle("GET /index.html", func(ex *Exchange) {
gotMethod = string(ex.RequestMethod()) gotMethod = string(ex.RequestMethodRaw())
gotMethodEnum = ex.RequestMethod()
gotURI = string(ex.RequestTarget()) gotURI = string(ex.RequestTarget())
gotHost = string(ex.RequestHeader("Host")) gotHost = string(ex.RequestHeader("Host"))
ex.WriteHeader(200) ex.WriteHeader(200)
@@ -213,6 +215,9 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
if gotMethod != "GET" { if gotMethod != "GET" {
t.Errorf("want method %q, got %q", "GET", gotMethod) 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" { if gotURI != "/index.html" {
t.Errorf("want URI %q, got %q", "/index.html", gotURI) t.Errorf("want URI %q, got %q", "/index.html", gotURI)
} }
+3 -3
View File
@@ -285,17 +285,17 @@ func (kvb *kvBuffer) getIdx(key string) int {
func (kvb *kvBuffer) getFoldIdx(key string) int { func (kvb *kvBuffer) getFoldIdx(key string) int {
for i, pair := range kvb.kvs { for i, pair := range kvb.kvs {
if pair.isValid() && asciiEqualFold(key, b2s(kvb.AtKey(i))) { if pair.isValid() && EqualFoldASCII(key, b2s(kvb.AtKey(i))) {
return i return i
} }
} }
return -1 return -1
} }
// asciiEqualFold reports whether a and b are equal under ASCII case folding. // EqualFoldASCII reports whether a and b are equal under ASCII case folding.
// Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte // Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte
// rune such as U+212A KELVIN SIGN can alias a header key. // rune such as U+212A KELVIN SIGN can alias a header key.
func asciiEqualFold(a, b string) bool { func EqualFoldASCII(a, b string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
} }