document all the things; improve req Query semantics; add Form.EnableBufferGrowth

This commit is contained in:
Patricio Whittingslow
2026-07-29 17:01:03 -03:00
parent 29fcec9cb7
commit 17bee3ed48
11 changed files with 183 additions and 111 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ func BenchmarkHandle(b *testing.B) {
handler: func(ex *Exchange) {
ex.StageHeader("Content-Type", "text/plain")
ex.StageHeaderInt("Content-Length", int64(len(benchBody)), 10)
data, present := ex.AppendQuery(buf[:0], "abc", true)
data, present := ex.RequestQueryAppend(buf[:0], "abc", true)
if !present || !internal.BytesEqual(data, expect) {
panic("invalid result")
}
+7 -3
View File
@@ -62,15 +62,19 @@ func ExampleMuxSlice_query_forms_multipart() {
mux.Handle("/users/{id}", func(ex *httphi.Exchange) {
userID := ex.PathValue("id")
fmt.Printf("someone requested data for user %s", userID)
fmt.Printf("someone requested data for user %s\n", userID)
})
mux.Handle("/query", func(ex *httphi.Exchange) {
// query parameter in URL.
const decodeQuery = true
const queryKey = "search"
queryValue, present := ex.AppendQuery(nil, queryKey, decodeQuery)
fmt.Printf("got query=%v %s=%s", present, queryKey, queryValue)
valueRaw, present := ex.RequestQueryValue(queryKey)
if !present {
return
}
valueDecoded, present := ex.RequestQueryAppend(nil, queryKey, decodeQuery)
fmt.Printf("got query=%v %s=%s (raw:%s)\n", present, queryKey, valueDecoded, valueRaw)
})
mux.Handle("GET /form", func(ex *httphi.Exchange) {
+56 -39
View File
@@ -49,11 +49,24 @@ type Exchange struct {
readErr error
}
// ExchangeConfig is the memory an [Exchange] is fixed to for the rest of its
// life by [Exchange.Configure]. A [Router] derives one per exchange from its
// [RouterConfig], which is what bounds the router's memory.
type ExchangeConfig struct {
RawBuf []byte
RequestBufferLim int
NumHeaderKVCap int
// RawBuf is the single buffer holding the request header, the response
// header and any surplus body. See [Exchange.UnsafeRawBuffer].
RawBuf []byte
// RequestBufferLim reserves the first bytes of RawBuf for the request
// header, the rest being the response. Configure panics if it exceeds RawBuf.
RequestBufferLim int
// NumHeaderKVCap is how many request header fields may be parsed. A request
// carrying more is answered 431, see [httpraw.ErrHeaderTooMany].
NumHeaderKVCap int
// NormalizeOutgoingKeys normalizes staged response header keys as they are
// written, i.e: "content-type" becomes "Content-Type".
NormalizeOutgoingKeys bool
// NoRequestBufferGrowth holds the request header to RequestBufferLim rather
// than growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
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].
@@ -443,7 +456,7 @@ func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) {
// left as they arrived, call [httpraw.Form.Decode] to decode them in place.
//
// Unlike http.Request.ParseForm the query string is not folded in, reach it with
// [Exchange.RequestQuery] or [Exchange.AppendQuery]. The body is consumed, so
// [Exchange.RequestQuery] or [Exchange.RequestQueryAppend]. The body is consumed, so
// call this before [Exchange.ReadBody].
//
// A request with no Content-Length has no body, RFC 9112 6.3, and yields an
@@ -615,47 +628,51 @@ func (exch *Exchange) RequestQuery() []byte {
return exch.RequestHeaderRaw().RequestQuery()
}
// AppendQuery appends the value of the first query parameter matching key to
// dst and reports whether the parameter was present. A parameter with no value
// ("?debug") and one with an empty value ("?debug=") are both present with
// nothing appended.
// RequestQueryValue returns an undecoded view of the first query parameter
// matching key and reports whether it was present. Keys are matched decoded, so
// key "a b" finds "a%20b" and "a+b"; a parameter whose key is a malformed
// escape is skipped. A parameter with no value ("?debug") and one with an empty
// value ("?debug=") are both present with a zero length view.
//
// Keys are matched decoded, so key "a b" finds "a%20b" and "a+b". Values are
// appended raw unless decoded is set, in which case percent escapes and '+' are
// decoded. A parameter whose value fails to decode is reported absent, and a
// parameter whose key fails to decode is skipped.
//
// dst doubles as scratch space for decoding candidate keys, so AppendQuery only
// allocates when dst lacks the capacity to hold the longest key it inspects.
func (exch *Exchange) AppendQuery(dst []byte, key string, decoded bool) (valueAppended []byte, present bool) {
// The view aliases the request buffer, so copy it to outlive the handler or use
// [Exchange.RequestQueryAppend] to decode it out.
func (exch *Exchange) RequestQueryValue(key string) (rawValue []byte, present bool) {
const plusAsSpace = true // Query strings are form encoded, unlike paths.
base := len(dst)
rawkey, rawval, rest := httpraw.NextQueryPair(exch.RequestQuery())
for ; rawkey != nil; rawkey, rawval, rest = httpraw.NextQueryPair(rest) {
if b2s(rawkey) != key {
// Key may be encoded: decode it over dst's free space and compare.
// A decoded key cannot appear raw, so this cannot alias a real key.
dst = slices.Grow(dst, len(rawkey))
scratch := dst[base : base+len(rawkey)]
n, err := httpraw.CopyDecodedPercentURL(scratch, rawkey, plusAsSpace)
if err != nil || b2s(scratch[:n]) != key {
continue // Malformed or different key, keep looking.
}
// Compare raw first: a key needing no decoding is the common case, and
// the decoding compare walks the key an escape at a time.
if b2s(rawkey) == key || httpraw.EqualDecodedPercentURL(rawkey, key, plusAsSpace) {
return rawval, true
}
if len(rawval) == 0 {
return dst[:base], true // Flag or empty value, nothing to append.
}
dst = slices.Grow(dst, len(rawval))
if !decoded {
return append(dst[:base], rawval...), true
}
n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawval)], rawval, plusAsSpace)
if err != nil {
return dst[:base], false // Do not hand back half a decode.
}
return dst[:base+n], true
}
return dst[:base], false
return nil, false
}
// RequestQueryAppend appends the value of the first query parameter matching key to
// dst and reports whether the parameter was present, matching keys as
// [Exchange.RequestQueryValue] does. A parameter with no value ("?debug") and
// one with an empty value ("?debug=") are both present with nothing appended.
//
// Values are appended raw unless decoded is set, in which case percent escapes
// and '+' are decoded. A parameter whose value fails to decode is reported
// absent, dst being left as it was rather than holding half a decode.
func (exch *Exchange) RequestQueryAppend(dst []byte, key string, decoded bool) (valueAppended []byte, present bool) {
const plusAsSpace = true // Query strings are form encoded, unlike paths.
rawval, present := exch.RequestQueryValue(key)
if !present || len(rawval) == 0 {
return dst, present
}
if !decoded {
return append(dst, rawval...), true
}
base := len(dst)
dst = slices.Grow(dst, len(rawval))
n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawval)], rawval, plusAsSpace)
if err != nil {
return dst[:base], false // Do not hand back half a decode.
}
return dst[:base+n], true
}
// RequestMethod returns the request line's method, i.e: "GET". See
+2 -2
View File
@@ -671,7 +671,7 @@ func TestExchangeAppendQuery(t *testing.T) {
var present bool
sm.Handle("/x", func(ex *Exchange) {
var value []byte
value, present = ex.AppendQuery(nil, test.key, test.decoded)
value, present = ex.RequestQueryAppend(nil, test.key, test.decoded)
got = string(value)
})
serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
@@ -696,7 +696,7 @@ func TestExchangeAppendQueryReusesBuffer(t *testing.T) {
sm.Handle("/x", func(ex *Exchange) {
var value []byte
allocs = testing.AllocsPerRun(50, func() {
value, _ = ex.AppendQuery(dst[:len("prefix:")], "q", true)
value, _ = ex.RequestQueryAppend(dst[:len("prefix:")], "q", true)
})
got = string(value) // Conversion allocates, keep it out of the measurement.
})
+1 -1
View File
@@ -242,7 +242,7 @@ func FuzzQueryAndForm(f *testing.F) {
if n > len(key) {
t.Fatalf("decoding key %q grew it to %d bytes", key, n)
}
if _, present := exch.AppendQuery(nil, string(dec[:n]), decoded); !present {
if _, present := exch.RequestQueryAppend(nil, string(dec[:n]), decoded); !present {
t.Fatalf("query pair %q absent from AppendQuery", key)
}
}
+6
View File
@@ -10,6 +10,12 @@ type Cookie struct {
kv KVBuffer
}
// EnableBufferGrowth allows the cookie's buffer to grow past what [Cookie.Reset] was
// handed. See [KVBuffer.EnableBufferGrowth].
func (c *Cookie) EnableBufferGrowth(enableBufferGrowth bool) {
c.kv.EnableBufferGrowth(enableBufferGrowth)
}
// Reset functions very similarly to [Header.Reset]. Can be used for in-place cookie parsing.
func (c *Cookie) Reset(buf []byte, capKV int) { c.kv.Reset(buf, capKV) }
+2
View File
@@ -11,6 +11,8 @@ type Form struct {
kv KVBuffer
}
// EnableBufferGrowth allows the form's buffer to grow past what [Form.Reset] was
// handed. See [KVBuffer.EnableBufferGrowth].
func (f *Form) EnableBufferGrowth(enableGrowth bool) { f.kv.EnableBufferGrowth(enableGrowth) }
// Reset discards parsed pairs and sets the buffer to parse in place.
+36
View File
@@ -634,6 +634,42 @@ func CopyDecodedPercentURL(dst, value []byte, plusAsSpace bool) (n int, err erro
}
}
// EqualDecodedPercentURL reports whether value, once decoded, equals want. It
// decodes as it compares so it needs no scratch buffer, and reports false on a
// malformed escape just as [CopyDecodedPercentURL] errors on one.
// plusAsSpace decodes '+' to ' ', correct for query and form-encoded data but
// NOT for path segments.
func EqualDecodedPercentURL(value []byte, want string, plusAsSpace bool) bool {
w := 0
for i := 0; i < len(value); {
var c byte
switch {
case value[i] == '%':
if i+2 >= len(value) {
return false // Truncated escape at end of value.
}
hi, okhi := unhexdigit(value[i+1])
lo, oklo := unhexdigit(value[i+2])
if !okhi || !oklo {
return false
}
c = hi<<4 | lo
i += 3
case plusAsSpace && value[i] == '+':
c = ' '
i++
default:
c = value[i]
i++
}
if w >= len(want) || want[w] != c {
return false
}
w++
}
return w == len(want)
}
// copyPlusDecoded copies src to dst replacing '+' with ' ' if plusAsSpace set.
func copyPlusDecoded(dst, src []byte, plusAsSpace bool) int {
n := copy(dst, src)
+48
View File
@@ -653,3 +653,51 @@ func TestHeader_FieldTableFullIsReported(t *testing.T) {
t.Fatalf("want ErrHeaderFieldsTooLarge, got %v", err)
}
}
// EqualDecodedPercentURL must agree with CopyDecodedPercentURL on every input:
// same decoded bytes, and false wherever the copying decoder reports an error.
func TestEqualDecodedPercentURL(t *testing.T) {
for _, value := range []string{
"", "plain", "a+b", "a%20b", "%41%42", "100%25", "a%2Fb", "+", "%2b",
"trailing%", "trailing%4", "%zz", "a%2", "%%", "a+b%20c", "%00",
} {
for _, plusAsSpace := range []bool{false, true} {
dst := make([]byte, len(value))
n, err := CopyDecodedPercentURL(dst, []byte(value), plusAsSpace)
// The copying decoder is the reference: whatever it produces is what
// an equal comparison must accept, and only that.
want := ""
if err == nil {
want = string(dst[:n])
}
got := EqualDecodedPercentURL([]byte(value), want, plusAsSpace)
if err != nil {
if got {
t.Errorf("%q plus=%v: malformed escape must not compare equal", value, plusAsSpace)
}
continue
}
if !got {
t.Errorf("%q plus=%v: want equal to its own decoding %q", value, plusAsSpace, want)
}
if EqualDecodedPercentURL([]byte(value), want+"x", plusAsSpace) {
t.Errorf("%q plus=%v: must not equal a longer want", value, plusAsSpace)
}
if want != "" && EqualDecodedPercentURL([]byte(value), want[:len(want)-1], plusAsSpace) {
t.Errorf("%q plus=%v: must not equal a shorter want", value, plusAsSpace)
}
}
}
}
// The comparison must not allocate: it is the reason a query lookup can return
// a view without scratch space.
func TestEqualDecodedPercentURLNoAlloc(t *testing.T) {
value := []byte("a%20long%2Dish+key")
allocs := testing.AllocsPerRun(100, func() {
EqualDecodedPercentURL(value, "a long-ish key", true)
})
if allocs != 0 {
t.Fatalf("EqualDecodedPercentURL allocated %v times, want 0", allocs)
}
}
+24 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/soypat/lneto/internal"
)
// KVBuffer is a common key-value store engine for Cookie, Form, and other HTTP abstractions that need
// KVBuffer is a common key-value store engine for Cookie, Form, Header and other HTTP abstractions that need
// a key-value store with underlying buffer memory.
type KVBuffer struct {
buf []byte
@@ -18,8 +18,12 @@ type KVBuffer struct {
func (mb *KVBuffer) free() int { return cap(mb.buf) - len(mb.buf) }
// BufferRaw returns the underlying buffer, its length being the portion in use.
// Stored pairs alias it, so writing to it mangles them.
func (mb *KVBuffer) BufferRaw() []byte { return mb.buf }
// EnableBufferGrowth allows the buffer to grow past the memory [KVBuffer.Reset]
// was handed. The setting outlives Reset; with growth off callers get [ErrBufferExhausted].
func (mb *KVBuffer) EnableBufferGrowth(enableGrowth bool) {
if enableGrowth {
mb.flags &^= flagNoBufferGrow
@@ -30,8 +34,11 @@ func (mb *KVBuffer) EnableBufferGrowth(enableGrowth bool) {
func (mb *KVBuffer) discardKVs() { mb.kvs = mb.kvs[:0] }
// BufferGrowthEnabled reports whether the buffer may grow, see [KVBuffer.EnableBufferGrowth].
func (mb *KVBuffer) BufferGrowthEnabled() bool { return !mb.flags.HasAny(flagNoBufferGrow) }
// ReadFromBytes appends buf to the underlying buffer, accumulating data to parse.
// Returns [ErrBufferExhausted] when buf does not fit and growth is disabled.
func (mb *KVBuffer) ReadFromBytes(buf []byte) error {
if len(buf) == 0 {
return io.ErrNoProgress // Nothing handed over, not a buffer problem.
@@ -48,6 +55,8 @@ func (mb *KVBuffer) ReadFromBytes(buf []byte) error {
return nil
}
// ReadLimited appends at most limit bytes read from r to the underlying buffer.
// A read returning data alongside [io.EOF] reports a nil error, later ones io.EOF.
func (mb *KVBuffer) ReadLimited(r io.Reader, limit int) (int, error) {
free := mb.free()
growthEnabled := mb.BufferGrowthEnabled()
@@ -72,6 +81,8 @@ func (mb *KVBuffer) ReadLimited(r io.Reader, limit int) (int, error) {
return n, err
}
// Reset discards all pairs and takes buf as the buffer to parse in place, nil
// reusing the current one. kvCap sizes the pair table. Only the growth setting survives.
func (mb *KVBuffer) Reset(buf []byte, kvCap int) {
if buf == nil {
mb.buf = mb.buf[:0]
@@ -82,6 +93,8 @@ func (mb *KVBuffer) Reset(buf []byte, kvCap int) {
mb.flags = mb.flags & flagNoBufferGrow // Only flag persisted is buffer grow config.
}
// CopyFrom replaces the receiver's contents with a copy of src, sharing no
// memory with it afterwards.
func (mb *KVBuffer) CopyFrom(src *KVBuffer) {
mb.buf = append(mb.buf[:0], src.buf...)
mb.kvs = append(mb.kvs[:0], src.kvs...)
@@ -124,6 +137,9 @@ func (mb *KVBuffer) HasKeyValue(key, value string) bool {
}
return false
}
// Add appends a pair, keeping any already sharing the key: use [KVBuffer.Set]
// to replace instead. Reports false if the buffer could not hold it.
func (mb *KVBuffer) Add(key, value string) (enoughSpace bool) {
mb.appendPair(key, value)
return mb.getIdx(key) >= 0
@@ -215,7 +231,11 @@ func (mb *KVBuffer) setInternal(key, value []byte) (enoughSpace bool) {
return true
}
// Len returns the number of slots stored, counting those [KVBuffer.Set] invalidated.
func (mb *KVBuffer) Len() int { return len(mb.kvs) }
// At returns the i'th pair in wire order. value is nil for a pair holding none,
// which is what tells a form's "ok" from "ok=".
func (mb *KVBuffer) At(i int) (key, value []byte) {
kv := mb.kvs[i]
if !kv.HasValue() {
@@ -233,7 +253,10 @@ func (mb *KVBuffer) setAt(i int, k, v []byte) {
}
}
// AtKey is [KVBuffer.At] limited to the i'th key.
func (mb *KVBuffer) AtKey(i int) (key []byte) { return mb.musttoken(mb.kvs[i].key) }
// AtValue is [KVBuffer.At] limited to the i'th value, nil when the pair holds none.
func (mb *KVBuffer) AtValue(i int) (key []byte) {
if !mb.kvs[i].HasValue() {
return nil
-64
View File
@@ -1,64 +0,0 @@
//go:build !tinygo && linux
package rawsock
import (
"net"
"runtime"
"testing"
)
// TestAcceptConnDoesNotAllocate pins down what AcceptConn exists for. A server
// that owns its connection storage still pays an allocation per connection if
// accepting one allocates, which is what syscall.Accept does with the peer
// address it returns.
func TestAcceptConnDoesNotAllocate(t *testing.T) {
var ln Listener
err := ln.Listen(0)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
addr := ln.Addr().String()
const n = 32
dialed := make([]net.Conn, 0, n)
defer func() {
for _, c := range dialed {
c.Close()
}
}()
for range n {
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
dialed = append(dialed, c)
}
var conn Conn
// The first accept warms whatever the runtime wants to warm, and is where
// the peer address is checked: reading it hands a value to a net.Addr
// interface, which allocates whatever the accept did.
if err = ln.AcceptConn(&conn); err != nil {
t.Fatal(err)
}
if conn.RemoteAddr().String() != dialed[0].LocalAddr().String() {
t.Errorf("accepted peer %s, dialer says %s", conn.RemoteAddr(), dialed[0].LocalAddr())
}
conn.Close()
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
for i := 1; i < n; i++ {
if err = ln.AcceptConn(&conn); err != nil {
t.Fatal(err)
}
conn.Close()
}
runtime.ReadMemStats(&after)
if allocs := after.Mallocs - before.Mallocs; allocs != 0 {
t.Errorf("AcceptConn allocated %d times over %d accepts, want 0", allocs, n-1)
}
}