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,
ResponseHeaderMinBufferSize: 32, // Shares the request buffer.
RequestNumHeaderKVCap: 32,
Backoff: func(uint) time.Duration { return time.Millisecond },
Mux: &mux,
})
if err != nil {
+17 -6
View File
@@ -35,7 +35,7 @@ type Exchange struct {
respHeaderOff uint16
respHeaderLen uint16
reqHdr httpraw.Header
pathValues []pathValue
pathValues []PathValue
hijacked bool
rw conn
@@ -253,11 +253,18 @@ 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) {
func (exch *Exchange) WriteHeader(code int) (n int, err error) {
if !exch.headerWritten {
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
@@ -709,9 +716,13 @@ func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]b
return dst[:base+n], nil
}
// RequestMethod returns the request line's method, i.e: "GET". See
// [MethodFromBytes] to compare it against a [Method].
func (exch *Exchange) RequestMethod() []byte {
// RequestMethod returns the request's [Method] enum.
func (exch *Exchange) RequestMethod() Method {
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()
}
+1 -1
View File
@@ -214,7 +214,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.RequestMethod())
gotMethod = string(ex.RequestMethodRaw())
gotURI = string(ex.RequestTarget())
gotHost = string(ex.RequestHeader("Host"))
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
// 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.
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
// 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
setPathVal bool
}
}
type pathValue struct {
// PathValue used to implement [Mux] interface. Stores http.Request.PathValue-like values.
type PathValue struct {
Key string // owned by mux.
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
// "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias
// 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] != '/' {
return false, false
}
@@ -160,7 +151,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte)
if n >= len(dstPathVals) {
return false, true
}
dstPathVals[n] = pathValue{Key: name, Value: requestPath}
dstPathVals[n] = PathValue{Key: name, Value: requestPath}
n++
}
return true, false
@@ -172,7 +163,7 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte)
if n >= len(dstPathVals) {
return false, true
}
dstPathVals[n] = pathValue{Key: name, Value: reqSeg}
dstPathVals[n] = PathValue{Key: name, Value: reqSeg}
n++
default:
@@ -205,6 +196,18 @@ func pathWildcard(segment string) (name string, isMulti, ok bool) {
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
// it to fit capacity registrations.
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.
// 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 {
if endpoint.method != MethUndefined && endpoint.method != method {
continue
}
// Method matches.
if endpoint.setPathVal {
if endpoint.pathVals > 0 {
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
return endpoint.path, endpoint.handler
}
@@ -230,6 +233,14 @@ func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []path
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
// 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.
@@ -242,6 +253,7 @@ func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
} else {
url = methodOrURL
}
v.pathVals = strings.Count(optMethodAndPath, "{")
v.method = method
v.path = url
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},
} {
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))
if tooShort {
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.
func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
path := []byte("/users/42/edit")
vals := make([]pathValue, 4)
vals := make([]PathValue, 4)
match, _ := SetPathValues(vals, "/users/{id}/edit", path)
if !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
// partial set or write out of range.
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 {
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
// agrees either way; only the bound bytes differ.
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 {
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"))
if !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
// unused slot.
func renderPathValues(vals []pathValue) string {
func renderPathValues(vals []PathValue) string {
var sb strings.Builder
for _, v := range vals {
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
// alias the request buffer, so nothing is copied per request.
func TestSetPathValuesNoAlloc(t *testing.T) {
vals := make([]pathValue, 8)
vals := make([]PathValue, 8)
path := []byte("/b/bk/o/a/b/c")
allocs := testing.AllocsPerRun(100, func() {
SetPathValues(vals, "/b/{bucket}/o/{obj...}", path)
@@ -164,7 +164,11 @@ type pathValueMux struct {
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 {
return m.pattern, m.handler
}
+3 -4
View File
@@ -84,8 +84,6 @@ type RouterConfig struct {
ResponseHeaderMinBufferSize int
// Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge].
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
// staged, i.e: "content-type" becomes "Content-Type".
@@ -186,7 +184,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
r.respBuf = cfg.ResponseHeaderMinBufferSize
r.mux = cfg.Mux
r.log = cfg.Logger
r.maxPathValues = cfg.MaxPathValues
r.maxPathValues = cfg.Mux.MaxPathValues()
r.normalizeKeys = cfg.NormalizeOutgoingKeys
// Freelist entries were sized by the outgoing configuration: recycling one
// 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]
rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
internal.SliceReuse(&r.globbuf, numgoro*rawBuflen)
maxPathValues := cfg.Mux.MaxPathValues()
for i := range numgoro {
// TODO exchange buffer alloc
goff := i * rawBuflen
@@ -220,7 +219,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
NumHeaderKVCap: cfg.RequestNumHeaderKVCap,
NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys,
NoRequestBufferGrowth: true, // Hard memory limit.
MaxPathValues: cfg.MaxPathValues,
MaxPathValues: maxPathValues,
})
go r.goroWorker(gen, jobqueue, cfg.Mux)
}
+6 -1
View File
@@ -196,8 +196,10 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
router Router
)
var gotMethod, gotURI, gotHost string
var gotMethodEnum Method
sm.Handle("GET /index.html", func(ex *Exchange) {
gotMethod = string(ex.RequestMethod())
gotMethod = string(ex.RequestMethodRaw())
gotMethodEnum = ex.RequestMethod()
gotURI = string(ex.RequestTarget())
gotHost = string(ex.RequestHeader("Host"))
ex.WriteHeader(200)
@@ -213,6 +215,9 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
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)
}
+3 -3
View File
@@ -285,17 +285,17 @@ func (kvb *kvBuffer) getIdx(key string) int {
func (kvb *kvBuffer) getFoldIdx(key string) int {
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 -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
// 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) {
return false
}