diff --git a/http/httphi/bench_test.go b/http/httphi/bench_test.go index 14a83cb..e28ff5b 100644 --- a/http/httphi/bench_test.go +++ b/http/httphi/bench_test.go @@ -3,6 +3,8 @@ package httphi import ( "io" "testing" + + "github.com/soypat/lneto/internal" ) // benchConn replays a fixed request and discards the response. It allocates @@ -48,17 +50,23 @@ func benchExchange(b *testing.B, conn conn) *Exchange { // BenchmarkHandle measures a whole exchange: read, parse, mux and respond. func BenchmarkHandle(b *testing.B) { + expect := []byte("123") + buf := make([]byte, 64) for _, bb := range []struct { name string request string handler HandlerFunc }{ { - name: "GETWithHeaders", - request: "GET / HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n", + name: "GETWithHeadersAndQuery", + request: "GET /?abc=123 HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n", handler: func(ex *Exchange) { ex.SetHeader("Content-Type", "text/plain") ex.SetHeaderInt("Content-Length", int64(len(benchBody)), 10) + data, present := ex.AppendQuery(buf[:0], "abc", true) + if !present || !internal.BytesEqual(data, expect) { + panic("invalid result") + } ex.Write(benchBody) }, }, @@ -78,7 +86,7 @@ func BenchmarkHandle(b *testing.B) { b.ReportAllocs() b.SetBytes(int64(len(bb.request))) b.ResetTimer() - for b.Loop() { + for i := 0; i < b.N; i++ { conn.rewind() exch.Release() exch.Acquire(conn) diff --git a/http/httphi/exchange_test.go b/http/httphi/exchange_test.go index 49e2277..3f4b045 100644 --- a/http/httphi/exchange_test.go +++ b/http/httphi/exchange_test.go @@ -190,7 +190,8 @@ func TestHandleRequestFields(t *testing.T) { t.Run(test.name, func(t *testing.T) { var gotMethod, gotURI, gotHost string var sm MuxSlice - sm.Handle(test.wantURI, func(ex *Exchange) { + route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path. + sm.Handle(route, func(ex *Exchange) { gotMethod = string(ex.RequestMethod()) gotURI = string(ex.RequestURI()) gotHost = string(ex.RequestHeader("Host")) @@ -453,3 +454,100 @@ func TestExchangeSetHeaderIntNoAlloc(t *testing.T) { t.Errorf("SetHeaderInt allocated %v times, want 0", allocs) } } + +// The Mux matches on the request path: a query string must not defeat routing. +func TestHandleMuxOnPath(t *testing.T) { + var sm MuxSlice + var gotPath, gotQuery string + sm.Handle("GET /search", func(ex *Exchange) { + gotPath = string(ex.RequestPath()) + ex.ForEachQueryRaw(func(rawkey, rawval []byte) bool { + gotQuery += string(rawkey) + "=" + string(rawval) + ";" + return true + }) + ex.WriteHeader(200) + }) + conn := serve(t, "GET /search?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n", &sm) + + if gotPath != "/search" { + t.Errorf("want path %q, got %q", "/search", gotPath) + } + if gotQuery != "q=go;n=1;" { + t.Errorf("want query %q, got %q", "q=go;n=1;", gotQuery) + } + if got := conn.ViewWritten(); !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") { + t.Errorf("want the handler to have run, got %q", got) + } +} + +func TestExchangeAppendQuery(t *testing.T) { + for _, test := range []struct { + uri string + key string + decoded bool + want string + wantPresent bool + }{ + {uri: "/x?q=go", key: "q", want: "go", wantPresent: true}, + {uri: "/x?q=go&n=1", key: "n", want: "1", wantPresent: true}, + {uri: "/x?a=1&a=2", key: "a", want: "1", wantPresent: true}, // First match wins. + {uri: "/x?q=go", key: "nope", want: "", wantPresent: false}, // Absent. + {uri: "/x", key: "q", want: "", wantPresent: false}, // No query at all. + {uri: "/x?debug&q=go", key: "debug", want: "", wantPresent: true}, // Flag: present, no value. + {uri: "/x?q=", key: "q", want: "", wantPresent: true}, // Present, empty. + // Decoding is opt-in and applies to the value only. + {uri: "/x?q=hello%20world", key: "q", want: "hello%20world", wantPresent: true}, + {uri: "/x?q=hello%20world", key: "q", decoded: true, want: "hello world", wantPresent: true}, + {uri: "/x?q=a+b", key: "q", want: "a+b", wantPresent: true}, + {uri: "/x?q=a+b", key: "q", decoded: true, want: "a b", wantPresent: true}, + // Keys are matched decoded: "a b" cannot appear raw. + {uri: "/x?a%20b=c", key: "a b", want: "c", wantPresent: true}, + {uri: "/x?a+b=c", key: "a b", want: "c", wantPresent: true}, + // Malformed escapes: a bad key is skipped, a bad value is not returned. + {uri: "/x?%zz=1&q=go", key: "q", want: "go", wantPresent: true}, + {uri: "/x?q=%zz", key: "q", decoded: true, want: "", wantPresent: false}, + {uri: "/x?q=%zz", key: "q", want: "%zz", wantPresent: true}, // Undecoded, passed through. + } { + var sm MuxSlice + var got string + var present bool + sm.Handle("/x", func(ex *Exchange) { + var value []byte + value, present = ex.AppendQuery(nil, test.key, test.decoded) + got = string(value) + }) + serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm) + + if present != test.wantPresent { + t.Errorf("%s key %q decoded=%v: want present=%v, got %v", test.uri, test.key, test.decoded, test.wantPresent, present) + } + if got != test.want { + t.Errorf("%s key %q decoded=%v: want %q, got %q", test.uri, test.key, test.decoded, test.want, got) + } + } +} + +// AppendQuery appends to dst, leaving what was already there untouched, and +// does not allocate when dst has the capacity. +func TestExchangeAppendQueryReusesBuffer(t *testing.T) { + var sm MuxSlice + var got string + var allocs float64 + dst := make([]byte, 0, 64) + sm.Handle("/x", func(ex *Exchange) { + var value []byte + allocs = testing.AllocsPerRun(50, func() { + value, _ = ex.AppendQuery(dst[:len("prefix:")], "q", true) + }) + got = string(value) // Conversion allocates, keep it out of the measurement. + }) + copy(dst[:cap(dst)], "prefix:") + serve(t, "GET /x?q=hello%20world HTTP/1.1\r\nHost: h\r\n\r\n", &sm) + + if got != "prefix:hello world" { + t.Errorf("want %q, got %q", "prefix:hello world", got) + } + if allocs != 0 { + t.Errorf("AppendQuery allocated %v times into a buffer with capacity, want 0", allocs) + } +} diff --git a/http/httphi/httphi.go b/http/httphi/httphi.go index 663d5c8..8a6b407 100644 --- a/http/httphi/httphi.go +++ b/http/httphi/httphi.go @@ -4,6 +4,7 @@ import ( "errors" "io" "log/slog" + "slices" "strconv" "sync" "sync/atomic" @@ -215,10 +216,10 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error { exch.WriteHeader(int(StatusBadRequest)) return errNoRequestProto } - // Mux URI. - uri := reqhdr.RequestURI() + // Mux on the request path: the query string is the handler's business. + path := reqhdr.RequestPath() meth := reqhdr.Method() - handler := mux.LookupHandler(MethodFromBytes(meth), uri) + handler := mux.LookupHandler(MethodFromBytes(meth), path) if handler != nil { handler(exch) exch.FlushHeader() @@ -589,6 +590,68 @@ func (exch *Exchange) RequestURI() []byte { return exch.RequestHeaderRaw().RequestURI() } +// RequestPath returns the request URI up to the query string. This is what the +// [Mux] matches on, i.e: "/search" for a request to "/search?q=go". +func (exch *Exchange) RequestPath() []byte { + return exch.RequestHeaderRaw().RequestPath() +} + +// ForEachQueryRaw iterates over the request's query string key-value pairs as +// they appear on the wire. See [httpraw.Header.ForEachQuery]. +func (exch *Exchange) ForEachQueryRaw(fn func(rawkey, rawval []byte) bool) { + exch.RequestHeaderRaw().ForEachQuery(fn) +} + +// 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. +// +// 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) { + const plusAsSpace = true // Query strings are form encoded, unlike paths. + base := len(dst) + valueAppended = dst + exch.ForEachQueryRaw(func(rawkey, rawval []byte) bool { + 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. + valueAppended = slices.Grow(valueAppended, len(rawkey)) + scratch := valueAppended[base : base+len(rawkey)] + n, err := httpraw.CopyDecodedPercentURL(scratch, rawkey, plusAsSpace) + if err != nil || b2s(scratch[:n]) != key { + return true // Malformed or different key, keep looking. + } + } + present = true + if len(rawval) == 0 { + return false + } + valueAppended = slices.Grow(valueAppended, len(rawval)) + if !decoded { + valueAppended = append(valueAppended[:base], rawval...) + return false + } + n, err := httpraw.CopyDecodedPercentURL(valueAppended[base:base+len(rawval)], rawval, plusAsSpace) + if err != nil { + present = false // Malformed value, do not hand back half a decode. + return false + } + valueAppended = valueAppended[:base+n] + return false + }) + if !present { + valueAppended = valueAppended[:base] + } + return valueAppended, present +} + func (exch *Exchange) RequestMethod() []byte { return exch.RequestHeaderRaw().Method() } diff --git a/http/httphi/zz_scratch_bench_test.go b/http/httphi/zz_scratch_bench_test.go new file mode 100644 index 0000000..5284850 --- /dev/null +++ b/http/httphi/zz_scratch_bench_test.go @@ -0,0 +1,145 @@ +package httphi + +import "testing" + +// Scratch benchmarks isolating query handling. Delete after diagnosis. + +const scratchRequest = "GET /?abc=123 HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n" + +func scratchExchange(b *testing.B) (*Exchange, *benchConn, *MuxSlice) { + b.Helper() + conn := &benchConn{request: scratchRequest} + exch := benchExchange(b, conn) + mux := new(MuxSlice) + return exch, conn, mux +} + +// A: whole exchange, handler writes body only. No query touched. +func BenchmarkScratchBodyOnly(b *testing.B) { + exch, conn, mux := scratchExchange(b) + mux.Handle("GET /", func(ex *Exchange) { ex.Write(benchBody) }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} + +// B: same plus header writes. Isolates SetHeader/SetHeaderInt. +func BenchmarkScratchHeaders(b *testing.B) { + exch, conn, mux := scratchExchange(b) + mux.Handle("GET /", func(ex *Exchange) { + ex.SetHeader("Content-Type", "text/plain") + ex.SetHeaderInt("Content-Length", int64(len(benchBody)), 10) + ex.Write(benchBody) + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} + +// C: iteration only, closure captures one counter. +func BenchmarkScratchForEachQuery(b *testing.B) { + exch, conn, mux := scratchExchange(b) + var seen int + mux.Handle("GET /", func(ex *Exchange) { + ex.ForEachQueryRaw(func(rawkey, rawval []byte) bool { + seen += len(rawkey) + return true + }) + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } + _ = seen +} + +// C2: closure that reassigns a captured slice, exactly AppendQuery's shape. +func BenchmarkScratchClosureSlice(b *testing.B) { + exch, conn, mux := scratchExchange(b) + buf := make([]byte, 64) + mux.Handle("GET /", func(ex *Exchange) { + out := buf[:0] + found := false + ex.ForEachQueryRaw(func(rawkey, rawval []byte) bool { + if b2s(rawkey) != "abc" { + return true + } + out = append(out, rawval...) + found = true + return false + }) + _ = found + _ = out + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} + +// C3: same but the captured slice is only read, never reassigned. +func BenchmarkScratchClosureNoReassign(b *testing.B) { + exch, conn, mux := scratchExchange(b) + buf := make([]byte, 64) + mux.Handle("GET /", func(ex *Exchange) { + n := 0 + ex.ForEachQueryRaw(func(rawkey, rawval []byte) bool { + n += copy(buf, rawval) + return false + }) + _ = n + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} + +// D: AppendQuery raw, into a buffer with capacity. +func BenchmarkScratchAppendQueryRaw(b *testing.B) { + exch, conn, mux := scratchExchange(b) + buf := make([]byte, 64) + mux.Handle("GET /", func(ex *Exchange) { + ex.AppendQuery(buf[:0], "abc", false) + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} + +// E: AppendQuery decoded. +func BenchmarkScratchAppendQueryDecoded(b *testing.B) { + exch, conn, mux := scratchExchange(b) + buf := make([]byte, 64) + mux.Handle("GET /", func(ex *Exchange) { + ex.AppendQuery(buf[:0], "abc", true) + }) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, mux, nopBackoff) + } +} diff --git a/http/httpraw/header.go b/http/httpraw/header.go index 7aa1001..746e2ce 100644 --- a/http/httpraw/header.go +++ b/http/httpraw/header.go @@ -361,6 +361,53 @@ func (h *Header) RequestURI() []byte { return h.getNonEmptyValue(h.requestURI) } +// RequestPath returns the request URI up to the query string, i.e: "/search" +// for "/search?q=go". Returns the whole URI if it contains no query string. +func (h *Header) RequestPath() []byte { + uri := h.RequestURI() + query := bytes.IndexByte(uri, '?') + if query < 0 { + return uri + } + return uri[:query] +} + +// ForEachQuery iterates over the request URI's query string key-value pairs as +// they appear on the wire, percent-encoded and with '+' undecoded. fn returns +// true to continue iterating, false to stop. +// +// A pair with no '=' yields a nil value, i.e: "debug" in "?debug&q=go", which +// distinguishes it from "?debug=" where the value is present and empty. Empty +// sequences are skipped, so "?&&q=go&" yields a single pair. Only '&' separates +// pairs and only the first '=' splits a pair. +func (h *Header) ForEachQuery(fn func(rawkey, rawval []byte) bool) { + uri := h.RequestURI() + start := bytes.IndexByte(uri, '?') + if start < 0 { + return + } + query := uri[start+1:] + for len(query) > 0 { + pair := query + amp := bytes.IndexByte(query, '&') + if amp >= 0 { + pair, query = query[:amp], query[amp+1:] + } else { + query = nil + } + if len(pair) == 0 { + continue // Empty sequence, see WHATWG URL urlencoded parsing. + } + key, value := pair, []byte(nil) + if eq := bytes.IndexByte(pair, '='); eq >= 0 { + key, value = pair[:eq], pair[eq+1:] + } + if !fn(key, value) { + return + } + } +} + // Protocol returns the request header's HTTP protocol. Usually "HTTP/1.1". func (h *Header) Protocol() []byte { return h.getNonEmptyValue(h.proto) diff --git a/http/httpraw/header_test.go b/http/httpraw/header_test.go index 1e8f3ec..69319e6 100644 --- a/http/httpraw/header_test.go +++ b/http/httpraw/header_test.go @@ -151,6 +151,85 @@ func strSameSite(mode http.SameSite) string { } } +func TestHeaderRequestPath(t *testing.T) { + for _, test := range []struct { + uri string + want string + }{ + {uri: "/", want: "/"}, + {uri: "/search?q=go", want: "/search"}, + {uri: "/search?", want: "/search"}, + {uri: "/a/b/c?x=1&y=2", want: "/a/b/c"}, + {uri: "/?q=go", want: "/"}, + } { + var h Header + err := h.ParseBytes(false, []byte("GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n")) + if err != nil { + t.Fatal(err) + } + if got := string(h.RequestPath()); got != test.want { + t.Errorf("uri %q: want path %q, got %q", test.uri, test.want, got) + } + } +} + +func TestHeaderForEachQuery(t *testing.T) { + for _, test := range []struct { + uri string + want string // "key=value" pairs joined by '|'; nil value shown as "key". + }{ + {uri: "/", want: ""}, + {uri: "/x?", want: ""}, + {uri: "/x?q=go", want: "q=go"}, + {uri: "/x?q=go&n=1", want: "q=go|n=1"}, + {uri: "/x?debug&q=go", want: "debug|q=go"}, // No '=' yields a nil value. + {uri: "/x?q=", want: "q="}, // Empty but present value. + {uri: "/x?&&q=go&", want: "q=go"}, // Empty sequences skipped. + {uri: "/x?=v", want: "=v"}, // Empty name is kept. + {uri: "/x?a=1&a=2", want: "a=1|a=2"}, // Duplicates all yielded. + {uri: "/x?a%20b=c%20d", want: "a%20b=c%20d"}, // Raw, undecoded. + {uri: "/x?a=b=c", want: "a=b=c"}, // Only first '=' splits. + } { + var h Header + err := h.ParseBytes(false, []byte("GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n")) + if err != nil { + t.Fatal(err) + } + var got []byte + h.ForEachQuery(func(rawkey, rawval []byte) bool { + if len(got) > 0 { + got = append(got, '|') + } + got = append(got, rawkey...) + if rawval != nil { + got = append(got, '=') + got = append(got, rawval...) + } + return true + }) + if string(got) != test.want { + t.Errorf("uri %q: want %q, got %q", test.uri, test.want, got) + } + } +} + +// fn returning false stops iteration. +func TestHeaderForEachQueryStop(t *testing.T) { + var h Header + err := h.ParseBytes(false, []byte("GET /x?a=1&b=2&c=3 HTTP/1.1\r\nHost: h\r\n\r\n")) + if err != nil { + t.Fatal(err) + } + visited := 0 + h.ForEachQuery(func(rawkey, rawval []byte) bool { + visited++ + return string(rawkey) != "b" + }) + if visited != 2 { + t.Errorf("want iteration stopped after 2 pairs, visited %d", visited) + } +} + func TestHeaderNormalizeKey(t *testing.T) { var tests = []struct { key string