add Go's ServeMux Request.PathValue access semantics to Exchange, Mux and MuxSlice

This commit is contained in:
Patricio Whittingslow
2026-07-29 16:26:20 -03:00
parent 546f11e375
commit 6b54f74dd5
9 changed files with 394 additions and 53 deletions
+26
View File
@@ -34,6 +34,7 @@ type Exchange struct {
respHeaderOff uint16
respHeaderLen uint16
reqHdr httpraw.Header
pathValues []pathValue
hijacked bool
rw conn
@@ -54,6 +55,9 @@ type ExchangeConfig struct {
NumHeaderKVCap int
NormalizeOutgoingKeys bool
NoRequestBufferGrowth bool
// MaxPathValues is how many wildcards a single pattern may bind, read back with
// [Exchange.PathValue]. A pattern with more never matches, see [SetPathValues].
MaxPathValues int
}
// HijackRaw is a low-level implementation of http.Hijacker interface.
@@ -99,6 +103,27 @@ 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]
}
// PathValue returns the segment the request path bound to the wildcard named
// key, or nil if the matched pattern has no such wildcard. It plays the part of
// http.Request.PathValue. See [SetPathValues] for the pattern syntax and for
// which segments a wildcard binds.
//
// sm.Handle("GET /users/{id}", func(exch *httphi.Exchange) {
// id := exch.PathValue("id") // "42" on a GET /users/42.
// })
func (exch *Exchange) PathValue(key string) []byte {
for i := range exch.pathValues {
if exch.pathValues[i].Key == key {
return exch.pathValues[i].Value
} else if exch.pathValues[i].Key == "" {
break // No more keys set.
}
}
return nil
}
// Acquire claims the exchange for conn and resets it to serve a new request,
@@ -120,6 +145,7 @@ func (exch *Exchange) Acquire(conn conn) bool {
exch.rw = conn
exch.headerWritten = false
exch.nextFree = nil
clear(exch.pathValues)
exch.reqHdr.Reset(nil, 0)
return true
}
+104 -8
View File
@@ -1,6 +1,7 @@
package httphi
import (
"bytes"
"io"
"strings"
"unsafe"
@@ -65,7 +66,7 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
// Mux on the request path: the query string is the handler's business.
path := reqhdr.RequestPath()
meth := reqhdr.Method()
matchedPattern, handler := mux.LookupHandler(MethodFromBytes(meth), b2s(path))
matchedPattern, handler := mux.LookupHandler(MethodFromBytes(meth), path, exch.pathValues)
if handler != nil {
exch.matchedPattern = matchedPattern
handler(exch)
@@ -98,8 +99,9 @@ type HandlerFunc func(ex *Exchange)
// replies 404 when it returns nil.
type Mux interface {
// LookupHandler matches the requestPath and method to a handler and returns it and the
// pattern it matched.
LookupHandler(get Method, requestPath string) (matchedPattern string, handler HandlerFunc)
// 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)
}
// MuxSlice is a [Mux] backed by a slice of registered endpoints, matched by
@@ -107,12 +109,102 @@ type Mux interface {
type MuxSlice struct {
// TODO: binary search worth it?
_handlers []struct {
method Method
path string
handler HandlerFunc
method Method
path string
handler HandlerFunc
setPathVal bool
}
}
type pathValue struct {
Key string // owned by mux.
Value []byte // points to raw exchange buffer.
}
// pathSeparator is shared so [SetPathValues] never converts a literal per call.
var pathSeparator = []byte{'/'}
// SetPathValues matches requestPath against pattern and binds its wildcards
// into dstPathVals, read back with [Exchange.PathValue]. Wildcards are whole
// segments as per http.ServeMux: "{name}" takes one non-empty segment,
// "{name...}" the rest including slashes, "{$}" only the path's end, and a
// trailing slash is an anonymous "{...}". i.e: "/b/{bucket}/o/{obj...}".
//
// 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) {
if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' {
return false, false
}
pattern, requestPath = pattern[1:], requestPath[1:]
n := 0
for {
if len(pattern) == 0 {
// Nothing left after a slash: an anonymous "..." taking the rest,
// which is why "/files/" matches "/files/a/b" and "/" matches all.
return true, false
}
patSeg, patRest, patMore := strings.Cut(pattern, "/")
reqSeg, reqRest, reqMore := bytes.Cut(requestPath, pathSeparator)
name, isMulti, isWildcard := pathWildcard(patSeg)
switch {
case isWildcard && name == "$":
// Matches the end of the path and nothing else, so it must be the
// last segment of the pattern and leave no path behind.
return !patMore && len(requestPath) == 0, false
case isWildcard && isMulti:
// Takes the remainder including slashes, possibly empty.
if name != "" {
if n >= len(dstPathVals) {
return false, true
}
dstPathVals[n] = pathValue{Key: name, Value: requestPath}
n++
}
return true, false
case isWildcard:
if len(reqSeg) == 0 {
return false, false // One segment means a non-empty one.
}
if n >= len(dstPathVals) {
return false, true
}
dstPathVals[n] = pathValue{Key: name, Value: reqSeg}
n++
default:
if b2s(reqSeg) != patSeg {
return false, false
}
}
if patMore != reqMore {
// One side has a further segment and the other does not, so
// "/health" misses "/health/" and "/files/" misses "/files".
return false, false
} else if !patMore {
return true, false // Both spent on the same segment.
}
pattern, requestPath = patRest, reqRest
}
}
// pathWildcard picks apart a "{name}" or "{name...}" pattern segment. It
// reports ok false for a literal segment, so "/b_{bucket}" is literal text and
// not a wildcard, matching ServeMux's rule that wildcards be whole segments.
func pathWildcard(segment string) (name string, isMulti, ok bool) {
if len(segment) < 2 || segment[0] != '{' || segment[len(segment)-1] != '}' {
return "", false, false
}
name = segment[1 : len(segment)-1]
if rest, found := strings.CutSuffix(name, "..."); found {
return rest, true, true
}
return name, false, true
}
// Reset discards all registered handlers, reusing the backing array and growing
// it to fit capacity registrations.
func (sm *MuxSlice) Reset(capacity int) {
@@ -121,13 +213,17 @@ 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 string) (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 path == endpoint.path {
if endpoint.setPathVal {
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
return endpoint.path, endpoint.handler
}
} else if b2s(path) == endpoint.path {
return endpoint.path, endpoint.handler
}
}
+222
View File
@@ -0,0 +1,222 @@
package httphi
import (
"strings"
"testing"
)
// SetPathValues must agree with net/http.ServeMux on which patterns match which
// paths and what each wildcard binds to. Every case below was taken from a run
// against a real ServeMux, so this table is an oracle, not a guess.
//
// The one documented deviation is percent-decoding: ServeMux unescapes segments
// before matching and binding, this does not. See TestSetPathValuesEscaping.
func TestSetPathValues(t *testing.T) {
for _, test := range []struct {
pattern string
path string
match bool
want string // "name=value" pairs joined by "|", in bind order.
}{
// Single segment wildcard binds exactly one non-empty segment.
{pattern: "/users/{id}", path: "/users/42", match: true, want: "id=42"},
{pattern: "/users/{id}", path: "/users/42/x", match: false},
{pattern: "/users/{id}", path: "/users/", match: false},
{pattern: "/users/{id}", path: "/users", match: false},
{pattern: "/users/{id}/edit", path: "/users/42/edit", match: true, want: "id=42"},
{pattern: "/{a}/{b}", path: "/x/y", match: true, want: "a=x|b=y"},
// "..." swallows the remainder, slashes included, and may bind empty.
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o/a/b/c", match: true, want: "bucket=bk|obj=a/b/c"},
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o/", match: true, want: "bucket=bk|obj="},
{pattern: "/b/{bucket}/o/{obj...}", path: "/b/bk/o", match: false},
{pattern: "/files/{p...}", path: "/files/", match: true, want: "p="},
{pattern: "/files/{p...}", path: "/files", match: false},
// {$} matches only the end of the path.
{pattern: "/{$}", path: "/", match: true},
{pattern: "/{$}", path: "/x", match: false},
{pattern: "/a/{$}", path: "/a/", match: true},
{pattern: "/a/{$}", path: "/a", match: false},
{pattern: "/a/{$}", path: "/a/b", match: false},
// A trailing slash is an anonymous "..." wildcard, binding nothing.
{pattern: "/files/", path: "/files/a/b", match: true},
{pattern: "/files/", path: "/files/", match: true},
{pattern: "/files/", path: "/files", match: false},
{pattern: "/", path: "/anything/at/all", match: true},
// Literal patterns match exactly, trailing slash included.
{pattern: "/health", path: "/health", match: true},
{pattern: "/health", path: "/health/", match: false},
// An empty segment never satisfies a single wildcard.
{pattern: "/a/{x}/b", path: "/a//b", match: false},
} {
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
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")
}
if match != test.match {
t.Fatalf("want match=%v, got %v", test.match, match)
}
if !match {
return
}
if got := renderPathValues(vals); got != test.want {
t.Errorf("want %q, got %q", test.want, got)
}
})
}
}
// Bound values must alias the request path buffer rather than copy it: the
// 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)
match, _ := SetPathValues(vals, "/users/{id}/edit", path)
if !match {
t.Fatal("want match")
}
if string(vals[0].Value) != "42" {
t.Fatalf("want id=42, got %q", vals[0].Value)
}
// Mutating the request buffer must show through the bound value.
path[7] = '9'
if string(vals[0].Value) != "92" {
t.Errorf("value must alias the request buffer, got %q", vals[0].Value)
}
}
// 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"))
if !tooShort {
t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot")
}
if match {
t.Error("want match=false when values could not be bound")
}
// A pattern that binds nothing needs no slots at all.
match, tooShort = SetPathValues(nil, "/health", []byte("/health"))
if !match || tooShort {
t.Errorf("want match with no slots needed, got match=%v tooShort=%v", match, tooShort)
}
}
// Percent escapes are compared and bound raw. ServeMux unescapes segment by
// 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)
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)
match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy"))
if !match {
t.Fatal("want match")
}
if got := string(vals[0].Value); got != "x%2Fy" {
t.Errorf("want raw %q, got %q", "x%2Fy", got)
}
}
// renderPathValues joins the bound pairs for comparison, stopping at the first
// unused slot.
func renderPathValues(vals []pathValue) string {
var sb strings.Builder
for _, v := range vals {
if v.Key == "" {
break
}
if sb.Len() > 0 {
sb.WriteByte('|')
}
sb.WriteString(v.Key)
sb.WriteByte('=')
sb.Write(v.Value)
}
return sb.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)
path := []byte("/b/bk/o/a/b/c")
allocs := testing.AllocsPerRun(100, func() {
SetPathValues(vals, "/b/{bucket}/o/{obj...}", path)
})
if allocs != 0 {
t.Fatalf("SetPathValues allocated %v times, want 0", allocs)
}
}
// pathValueMux binds one wildcard pattern, standing in for a [Mux] that
// supports them until MuxSlice sets setPathVal.
type pathValueMux struct {
pattern string
handler HandlerFunc
}
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
}
return "", nil
}
// A wildcard bound by one request must not be readable by the next request the
// same pooled exchange serves. A literal pattern binds nothing, so it never
// overwrites the previous request's slots, and the values it would leak alias a
// buffer the new request has already overwritten.
func TestExchangePathValueClearedBetweenRequests(t *testing.T) {
exch := new(Exchange)
exch.Configure(ExchangeConfig{
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: 4,
})
// First request binds id=42 off a wildcard pattern.
var gotFirst string
wildcard := &pathValueMux{pattern: "/users/{id}", handler: func(e *Exchange) {
gotFirst = string(e.PathValue("id"))
e.WriteHeader(200)
}}
conn := newConn("GET /users/42 HTTP/1.1\r\nHost: h\r\n\r\n")
conn.Hangup()
if !exch.Acquire(conn) {
t.Fatal("fresh exchange failed to acquire")
}
if err := Handle(exch, wildcard, nopBackoff); err != nil {
t.Fatalf("first request: %s", err)
}
exch.Release()
if gotFirst != "42" {
t.Fatalf("want id=42 bound on the first request, got %q", gotFirst)
}
// Second request matches a literal pattern, which binds nothing at all.
var leaked []byte
var sm MuxSlice
sm.Handle("/health", func(e *Exchange) {
leaked = e.PathValue("id")
e.WriteHeader(200)
})
conn2 := newConn("GET /health HTTP/1.1\r\nHost: h\r\n\r\n")
conn2.Hangup()
if !exch.Acquire(conn2) {
t.Fatal("released exchange failed to re-acquire")
}
if err := Handle(exch, &sm, nopBackoff); err != nil {
t.Fatalf("second request: %s", err)
}
if leaked != nil {
t.Errorf("want no path value on a literal route, got id=%q from the previous request", leaked)
}
}
+7
View File
@@ -52,6 +52,7 @@ type Router struct {
reqBuf int
respBuf int
reqNumHeaderCap int
maxPathValues int
normalizeKeys bool
pendingConns chan job
mux Mux
@@ -83,6 +84,8 @@ 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".
@@ -180,11 +183,13 @@ func (r *Router) Configure(cfg RouterConfig) error {
gen := r.gen.Load()
numgoro := cfg.FixedNumGoroutines
workerMode := cfg.workerMode()
r.reqNumHeaderCap = cfg.RequestNumHeaderKVCap
r.reqBuf = cfg.RequestHeaderBufferSize
r.respBuf = cfg.ResponseHeaderMinBufferSize
r.mux = cfg.Mux
r.log = cfg.Logger
r.maxPathValues = cfg.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.
@@ -218,6 +223,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
NumHeaderKVCap: cfg.RequestNumHeaderKVCap,
NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys,
NoRequestBufferGrowth: true, // Hard memory limit.
MaxPathValues: cfg.MaxPathValues,
})
go r.goroWorker(gen, jobqueue, cfg.Mux)
}
@@ -383,6 +389,7 @@ func (r *Router) getExchLocked(conn conn) (exch *Exchange) {
NumHeaderKVCap: r.reqNumHeaderCap,
NormalizeOutgoingKeys: r.normalizeKeys,
NoRequestBufferGrowth: true,
MaxPathValues: r.maxPathValues,
})
exch.Acquire(conn) // Fresh exchange, CAS cannot fail.
return exch
+4 -1
View File
@@ -22,6 +22,9 @@ func (f *Form) Reset(buf []byte, capKV int) {
// ParseBytes copies the argument bytes to the Form's underlying buffer and parses them.
func (f *Form) ParseBytes(b []byte) error {
f.Reset(nil, 0)
if len(b) == 0 {
return nil // An empty body is an empty form, not a failure to read one.
}
err := f.kv.ReadFromBytes(b)
if err != nil {
return err
@@ -54,7 +57,7 @@ func (f *Form) Decode() error {
return err
} else if len(v) == 0 {
if nk != len(k) {
f.kv.setAt(i, k, v)
f.kv.setAt(i, k[:nk], v) // k[:nk]: the decoded key is shorter.
}
continue
}
+23
View File
@@ -66,6 +66,29 @@ func TestFormDecode(t *testing.T) {
}
}
// Decoding a valueless key that shrinks must rewrite the key without inventing
// a value: the pair has no '=' before Decode and must have none after.
func TestFormDecodeValuelessKeyShrinks(t *testing.T) {
var f Form
const body = "a=1&o%6Bay"
if err := f.ParseBytes([]byte(body)); err != nil {
t.Fatal(err)
}
if err := f.Decode(); err != nil {
t.Fatal(err)
}
const want = "a=1|okay"
if got := render(&f); got != want {
t.Errorf("want %q, got %q", want, got)
}
if _, value := f.Pair(1); value != nil {
t.Errorf("want valueless pair to stay valueless, got value %q", value)
}
if !f.Has("okay") {
t.Error("want decoded valueless key present")
}
}
// A malformed escape must be reported, never silently passed through.
func TestFormDecodeMalformed(t *testing.T) {
for _, body := range []string{"q=%zz", "%zz=v", "q=%4"} {
-38
View File
@@ -241,49 +241,11 @@ func (h *Header) SetInt(key string, value int64, base int) {
// Calling Set mangles the buffer.
func (h *Header) Set(key, value string) (enoughSpace bool) {
return h.hbuf.kv.Set(key, value)
// useKv := h.takeReusableSlot(key)
// if useKv == nil {
// h.hbuf.kv.appendPair(key, value)
// } else {
// useKv.value = h.hbuf.kv.reuseOrAppend(useKv.value, value)
// }
}
// takeReusableSlot returns the valid key-value entry for key with the largest
// value buffer (best candidate for in-place reuse) and invalidates any other
// entries sharing the key. Returns nil if the key is not present.
func (h *Header) takeReusableSlot(key string) *argsKV {
// hb := &h.hbuf
var useKv *argsKV
// for i := 0; i < len(hb.headers); i++ {
// // Search for key-value with largest buffer for value to store value reusing buffer.
// gotkv := &hb.headers[i]
// if gotkv.isValidHeader() && b2s(hb.musttoken(gotkv.key)) == key {
// if useKv == nil {
// useKv = gotkv
// } else if gotkv.value.len > useKv.value.len {
// useKv.invalidate()
// useKv = gotkv
// } else {
// gotkv.invalidate()
// }
// }
// }
return useKv
}
// Get gets the first exact-match value of a key found in the headers. Use [Header.ForEach] to find multiple values corresponding to same key.
func (h *Header) Get(key string) []byte {
return h.hbuf.kv.Get(key)
// debuglog("http:get:start")
// kv := h.peekHeader(key)
// if kv.isValidHeader() {
// debuglog("http:get:found")
// return h.hbuf.musttoken(kv.value)
// }
// debuglog("http:get:notfound")
// return nil
}
// GetFold gets the first value whose key matches key under ASCII case-insensitive
+4 -2
View File
@@ -225,9 +225,11 @@ func (mb *KVBuffer) At(i int) (key, value []byte) {
}
func (mb *KVBuffer) setAt(i int, k, v []byte) {
mb.flags |= flagMangledBuffer
// Route through slice, not bytes2tok: a nil v is a pair with no '=' and must
// stay absent rather than trip the alias check on a nil pointer.
mb.kvs[i] = argsKV{
key: bytes2tok(mb.buf, k),
value: bytes2tok(mb.buf, v),
key: mb.slice(k),
value: mb.slice(v),
}
}
+4 -4
View File
@@ -12,10 +12,10 @@ var (
errNoProto = errors.New("missing protocol, HTTP/0.9 unsupported")
// ErrNeedMoreData signals a parser was handed an incomplete buffer: append
// more data to it and call again.
ErrNeedMoreData = errors.New("need more data: cannot find trailing lf/delimiter")
errNoBoundary = errors.New("httpraw: multipart boundary not set")
errUnparsed = errors.New("need to finish parsing")
errInvalidName = errors.New("invalid header name")
ErrNeedMoreData = errors.New("need more data: cannot find trailing lf/delimiter")
errNoBoundary = errors.New("httpraw: multipart boundary not set")
errUnparsed = errors.New("need to finish parsing")
errInvalidName = errors.New("invalid header name")
// ErrBufferExhausted signals a buffer with no room left for the data being
// written and no permission to grow, see [KVBuffer.EnableBufferGrowth].
// Enlarging the buffer handed to Reset is the only fix; a server answers it