From 34b33be41f519134fe1b3780f352a90c107d144c Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 13 Jun 2026 18:01:44 -0700 Subject: [PATCH 1/3] perf: optimize core hot paths (chain, context, binding, responses) - echo: compile global/pre middleware chains once instead of per request, eliminating per-request closure allocations (5 mw: 101ns/5allocs -> 34ns/0allocs) - context: zero-copy String/HTML/JSONP writes, reuse delayedStatusWriter (guarded against re-entrant c.JSON) and the store map across requests, drop deferred unlock on Get/Set, single-key QueryParam fast path (199ns/4allocs -> 41ns/0allocs) - bind: cache per-type struct field metadata (bindData -48%, query Bind -28%) - add hot-path benchmark suite and pooling/dispatch regression tests Co-Authored-By: Claude Opus 4.8 (1M context) --- bind.go | 86 ++++++++++-- context.go | 112 +++++++++++++--- dispatch_pool_test.go | 101 ++++++++++++++ echo.go | 45 +++++-- middleware/randomstring_bench_test.go | 50 +++++++ middleware/randomstring_concurrent_test.go | 37 ++++++ middleware/secure.go | 25 ++-- middleware/util.go | 29 +++- perf_bench_test.go | 146 +++++++++++++++++++++ query_fastpath_test.go | 66 ++++++++++ 10 files changed, 651 insertions(+), 46 deletions(-) create mode 100644 dispatch_pool_test.go create mode 100644 middleware/randomstring_bench_test.go create mode 100644 middleware/randomstring_concurrent_test.go create mode 100644 perf_bench_test.go create mode 100644 query_fastpath_test.go diff --git a/bind.go b/bind.go index d320e2ba4..4713afde2 100644 --- a/bind.go +++ b/bind.go @@ -13,6 +13,7 @@ import ( "reflect" "strconv" "strings" + "sync" "time" ) @@ -136,6 +137,73 @@ func (b *DefaultBinder) Bind(c *Context, target any) error { return BindBody(c, target) } +// bindFieldMeta is the cached, type-level reflection metadata for a single struct field. Reading struct +// tags (reflect.StructTag.Get) parses the tag string on every call, so for binding-heavy endpoints we +// compute it once per struct type and reuse it across requests (see bindStructMeta). Only type-level data +// is cached here; per-request, per-instance reflect.Value operations still happen in bindData. +type bindFieldMeta struct { + index int // field index within the struct + // fieldKind is the DECLARED field kind (typeField.Type.Kind()), used only for unmarshal dispatch. + // It is intentionally not the post-anonymous-pointer-deref live kind; bindData computes that + // separately as structFieldKind where needed. + fieldKind reflect.Kind + anonymous bool // reflect.StructField.Anonymous + formatTag string // value of the `format` struct tag + // binding-source tag values. bindData is only ever called with one of these four tags (see the + // callers BindPathValues/BindQueryParams/BindBody/BindHeaders). Keep these fields, the four + // f.Tag.Get(...) lines in bindMetaFor, and the tagName switch in sync if a source is ever added. + param, query, form, header string +} + +// tagName returns the field's tag value for the given binding source tag. +// Keep in sync with the tag fields above and the f.Tag.Get calls in bindMetaFor. +func (m *bindFieldMeta) tagName(tag string) string { + switch tag { + case "param": + return m.param + case "query": + return m.query + case "form": + return m.form + case "header": + return m.header + default: + return "" + } +} + +// bindStructMeta is the cached field metadata for a whole struct type, in declaration order. +type bindStructMeta struct { + fields []bindFieldMeta +} + +// bindStructCache memoizes bindStructMeta keyed by struct reflect.Type. Concurrent double-computation is +// harmless because the result is deterministic and idempotent. +var bindStructCache sync.Map // map[reflect.Type]*bindStructMeta + +func bindMetaFor(typ reflect.Type) *bindStructMeta { + if cached, ok := bindStructCache.Load(typ); ok { + return cached.(*bindStructMeta) + } + n := typ.NumField() + meta := &bindStructMeta{fields: make([]bindFieldMeta, n)} + for i := 0; i < n; i++ { + f := typ.Field(i) + meta.fields[i] = bindFieldMeta{ + index: i, + anonymous: f.Anonymous, + fieldKind: f.Type.Kind(), + formatTag: f.Tag.Get("format"), + param: f.Tag.Get("param"), + query: f.Tag.Get("query"), + form: f.Tag.Get("form"), + header: f.Tag.Get("header"), + } + } + bindStructCache.Store(typ, meta) + return meta +} + // bindData will bind data ONLY fields in destination struct that have EXPLICIT tag func bindData(destination any, data map[string][]string, tag string, dataFiles map[string][]*multipart.FileHeader) error { if destination == nil || (len(data) == 0 && len(dataFiles) == 0) { @@ -185,10 +253,11 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m return errors.New("binding element must be a struct") } - for i := 0; i < typ.NumField(); i++ { // iterate over all destination fields - typeField := typ.Field(i) - structField := val.Field(i) - if typeField.Anonymous { + meta := bindMetaFor(typ) + for fi := range meta.fields { // iterate over all destination fields + fm := &meta.fields[fi] + structField := val.Field(fm.index) + if fm.anonymous { if structField.Kind() == reflect.Pointer { structField = structField.Elem() } @@ -197,8 +266,8 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m continue } structFieldKind := structField.Kind() - inputFieldName := typeField.Tag.Get(tag) - if typeField.Anonymous && structFieldKind == reflect.Struct && inputFieldName != "" { + inputFieldName := fm.tagName(tag) + if fm.anonymous && structFieldKind == reflect.Struct && inputFieldName != "" { // if anonymous struct with query/param/form tags, report an error return errors.New("query/param/form tags are not allowed with anonymous struct field") } @@ -248,15 +317,14 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m // but it is smart enough to handle niche cases like `*int`,`*[]string`,`[]*int` . // try unmarshalling first, in case we're dealing with an alias to an array type - if ok, err := unmarshalInputsToField(typeField.Type.Kind(), inputValue, structField); ok { + if ok, err := unmarshalInputsToField(fm.fieldKind, inputValue, structField); ok { if err != nil { return fmt.Errorf("%s: %w", inputFieldName, err) } continue } - formatTag := typeField.Tag.Get("format") - if ok, err := unmarshalInputToField(typeField.Type.Kind(), inputValue[0], structField, formatTag); ok { + if ok, err := unmarshalInputToField(fm.fieldKind, inputValue[0], structField, fm.formatTag); ok { if err != nil { return fmt.Errorf("%s: %w", inputFieldName, err) } diff --git a/context.go b/context.go index 4114ed4e9..471d6e2e1 100644 --- a/context.go +++ b/context.go @@ -19,6 +19,30 @@ import ( "path/filepath" "strings" "sync" + "unsafe" +) + +// stringToBytes returns a []byte view over s without copying, avoiding the allocation+copy of []byte(s) +// on the response write path (the zero-copy technique used by fasthttp/fiber). +// +// Contract — all of the following must hold at every call site: +// - The result is read-only: writing through it is undefined behaviour. +// - The callee must NOT retain the slice beyond the call. It is only passed to the response +// Writer's Write, whose io.Writer contract forbids retaining/mutating the argument. Note the +// concrete writer may be a wrapping ResponseWriter (e.g. gzip); such writers must copy, not alias. +// - s must stay reachable for as long as the slice is used: the slice aliases s's backing array. +func stringToBytes(s string) []byte { + if s == "" { + return nil + } + return unsafe.Slice(unsafe.StringData(s), len(s)) +} + +// jsonpOpen and jsonpClose are the constant byte wrappers for JSONP payloads, kept as package-level +// slices to avoid allocating them on every JSONP response. +var ( + jsonpOpen = []byte("(") + jsonpClose = []byte(");") ) const ( @@ -49,6 +73,14 @@ type Context struct { route *RouteInfo pathValues *PathValues + // handler is the route handler resolved during routing. It is invoked by the terminal of the global + // middleware chain (see Echo.buildRouterChains) so that the chain can be compiled once and reused. + handler HandlerFunc + + // dsw is reused by json() so that each JSON response does not heap-allocate a delayedStatusWriter. + // It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. + dsw delayedStatusWriter + store map[string]any echo *Echo logger *slog.Logger @@ -73,9 +105,9 @@ func NewContext(r *http.Request, w http.ResponseWriter, opts ...any) *Context { } func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context { + // store is created lazily by Set (and reset to nil by Reset), so we deliberately do not allocate a map here. c := &Context{ pathValues: nil, - store: make(map[string]any), echo: e, logger: nil, } @@ -109,10 +141,14 @@ func (c *Context) Reset(r *http.Request, w http.ResponseWriter) { c.orgResponse.reset(w) c.response = c.orgResponse c.query = nil - c.store = nil + // clear (rather than nil) keeps the map allocated on the pooled Context so that requests using Set + // do not allocate a fresh map each time. clear(nil) is a no-op. + clear(c.store) c.logger = c.echo.Logger c.route = nil + c.handler = nil + c.dsw = delayedStatusWriter{} c.path = "" // NOTE: empty by setting length to 0. PathValues has to have capacity of c.echo.contextPathParamAllocSize at all times *c.pathValues = (*c.pathValues)[:0] @@ -297,10 +333,38 @@ func (c *Context) setPathValues(pv *PathValues) { // QueryParam returns the query param for the provided name. func (c *Context) QueryParam(name string) string { - if c.query == nil { - c.query = c.request.URL.Query() + // If the full query map was already built (e.g. by QueryParams), use it. Otherwise look the single + // key up directly from the raw query, avoiding the url.Values map allocation for the common case of + // reading only a few params. The result is identical to url.Values.Get on the parsed query. + if c.query != nil { + return c.query.Get(name) + } + return getRawQueryParam(c.request.URL.RawQuery, name) +} + +// getRawQueryParam returns the first value for name parsed directly from a raw URL query string. It +// matches url.Values.Get over url.ParseQuery output: first match wins, '+' decodes to space, percent +// escapes are decoded, segments containing ';' are skipped, and pairs whose key or value fail to +// unescape are skipped. It avoids allocating the full url.Values map for single-key lookups. +func getRawQueryParam(query, name string) string { + for query != "" { + var seg string + seg, query, _ = strings.Cut(query, "&") + if seg == "" || strings.Contains(seg, ";") { + continue + } + key, value, _ := strings.Cut(seg, "=") + k, err := url.QueryUnescape(key) + if err != nil || k != name { + continue + } + v, err := url.QueryUnescape(value) + if err != nil { + continue + } + return v } - return c.query.Get(name) + return "" } // QueryParamOr returns the query param or default value for the provided name. @@ -390,20 +454,21 @@ func (c *Context) Cookies() []*http.Cookie { // Get retrieves data from the context. // Method returns any(nil) when key does not exist which is different from typed nil (eg. []byte(nil)). func (c *Context) Get(key string) any { + // Unlock without defer to avoid the deferred-call overhead on this hot path. c.lock.RLock() - defer c.lock.RUnlock() - return c.store[key] + v := c.store[key] + c.lock.RUnlock() + return v } // Set saves data in the context. func (c *Context) Set(key string, val any) { c.lock.Lock() - defer c.lock.Unlock() - if c.store == nil { c.store = make(map[string]any) } c.store[key] = val + c.lock.Unlock() } // Bind binds path params, query params and the request body into provided type `i`. The default binder @@ -445,7 +510,7 @@ func (c *Context) Render(code int, name string, data any) (err error) { // HTML sends an HTTP response with status code. func (c *Context) HTML(code int, html string) (err error) { - return c.HTMLBlob(code, []byte(html)) + return c.HTMLBlob(code, stringToBytes(html)) } // HTMLBlob sends an HTTP blob response with status code. @@ -455,19 +520,22 @@ func (c *Context) HTMLBlob(code int, b []byte) (err error) { // String sends a string response with status code. func (c *Context) String(code int, s string) (err error) { - return c.Blob(code, MIMETextPlainCharsetUTF8, []byte(s)) + return c.Blob(code, MIMETextPlainCharsetUTF8, stringToBytes(s)) } func (c *Context) jsonPBlob(code int, callback string, i any) (err error) { c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8) c.response.WriteHeader(code) - if _, err = c.response.Write([]byte(callback + "(")); err != nil { + if _, err = c.response.Write(stringToBytes(callback)); err != nil { + return + } + if _, err = c.response.Write(jsonpOpen); err != nil { return } if err = c.echo.JSONSerializer.Serialize(c, i, ""); err != nil { return } - if _, err = c.response.Write([]byte(");")); err != nil { + if _, err = c.response.Write(jsonpClose); err != nil { return } return @@ -480,7 +548,16 @@ func (c *Context) json(code int, i any, indent string) error { // (global) error handler decides correct status code for the error to be sent to the client. // For that we need to use writer that can store the proposed status code until the first Write is called. resp := c.Response() - c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code}) + // Reuse the Context-owned delayedStatusWriter to avoid heap-allocating one per JSON response. + // If we are already nested inside a delayed write (rare: a serializer or handler calling c.JSON + // re-entrantly), allocate a fresh writer so the outer call's writer (which is &c.dsw) is not + // clobbered — reusing c.dsw here would make it reference itself. + if _, nested := resp.(*delayedStatusWriter); nested { + c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code}) + } else { + c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code} + c.SetResponse(&c.dsw) + } defer c.SetResponse(resp) return c.echo.JSONSerializer.Serialize(c, i, indent) @@ -512,13 +589,16 @@ func (c *Context) JSONP(code int, callback string, i any) (err error) { func (c *Context) JSONPBlob(code int, callback string, b []byte) (err error) { c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8) c.response.WriteHeader(code) - if _, err = c.response.Write([]byte(callback + "(")); err != nil { + if _, err = c.response.Write(stringToBytes(callback)); err != nil { + return + } + if _, err = c.response.Write(jsonpOpen); err != nil { return } if _, err = c.response.Write(b); err != nil { return } - _, err = c.response.Write([]byte(");")) + _, err = c.response.Write(jsonpClose) return } diff --git a/dispatch_pool_test.go b/dispatch_pool_test.go new file mode 100644 index 000000000..b2912139f --- /dev/null +++ b/dispatch_pool_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestContextResetClearsStore guards the clear(c.store) reuse: a pooled Context must not leak store +// values from a previous request into the next one, and Set must still work after a clear-based Reset. +func TestContextResetClearsStore(t *testing.T) { + e := New() + c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder()) + c.Set("secret", "req1") + assert.Equal(t, "req1", c.Get("secret")) + + c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder()) + assert.Nil(t, c.Get("secret"), "store must not leak across Reset") + + c.Set("k", "req2") // Set must still work after clear-based reset + assert.Equal(t, "req2", c.Get("k")) +} + +// TestContextJSONStatusAcrossReset guards the reused delayedStatusWriter (c.dsw): a second JSON +// response on a pooled+Reset Context must use the new status, not inherit the previous one. +func TestContextJSONStatusAcrossReset(t *testing.T) { + e := New() + c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder()) + assert.NoError(t, c.JSON(http.StatusTeapot, map[string]int{"a": 1})) + + rec2 := httptest.NewRecorder() + c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), rec2) + assert.NoError(t, c.JSON(http.StatusCreated, map[string]int{"b": 2})) + assert.Equal(t, http.StatusCreated, rec2.Code) + assert.JSONEq(t, `{"b":2}`, rec2.Body.String()) +} + +// TestNestedJSONUsesFreshDelayedWriter guards the nested c.JSON case: a serializer that calls c.JSON +// re-entrantly must not corrupt the outer delayedStatusWriter (regression test for c.dsw self-reference). +func TestNestedJSONUsesFreshDelayedWriter(t *testing.T) { + e := New() + e.JSONSerializer = nestedJSONSerializer{} + rec := httptest.NewRecorder() + c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec) + assert.NoError(t, c.JSON(http.StatusOK, map[string]int{"outer": 1})) + assert.Equal(t, http.StatusOK, rec.Code) +} + +type nestedJSONSerializer struct{} + +func (nestedJSONSerializer) Serialize(c *Context, i any, indent string) error { + if m, ok := i.(map[string]int); ok && m["outer"] == 1 { + // re-enter c.JSON once before encoding the outer payload + if err := c.JSON(http.StatusOK, map[string]int{"inner": 2}); err != nil { + return err + } + } + return (DefaultJSONSerializer{}).Serialize(c, i, indent) +} + +func (nestedJSONSerializer) Deserialize(c *Context, i any) error { + return (DefaultJSONSerializer{}).Deserialize(c, i) +} + +// TestGlobalMiddlewareRunsOnNotFoundAndMethodNotAllowed pins the dispatch contract: global (Use) and +// pre (Pre) middleware must execute even when the router returns 404 / 405 / OPTIONS handlers. +func TestGlobalMiddlewareRunsOnNotFoundAndMethodNotAllowed(t *testing.T) { + cases := []struct { + name, method, path string + code int + }{ + {"404", http.MethodGet, "/missing", http.StatusNotFound}, + {"405", http.MethodPost, "/", http.StatusMethodNotAllowed}, + {"OPTIONS", http.MethodOptions, "/", http.StatusNoContent}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := New() + var pre, use bool + e.Pre(func(n HandlerFunc) HandlerFunc { + return func(c *Context) error { pre = true; return n(c) } + }) + e.Use(func(n HandlerFunc) HandlerFunc { + return func(c *Context) error { use = true; return n(c) } + }) + e.GET("/", func(c *Context) error { return c.String(http.StatusOK, "ok") }) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil)) + + assert.True(t, pre, "pre-middleware must run on %s", tc.name) + assert.True(t, use, "global middleware must run on %s", tc.name) + assert.Equal(t, tc.code, rec.Code) + }) + } +} diff --git a/echo.go b/echo.go index 5e706f8bd..f22d2dd99 100644 --- a/echo.go +++ b/echo.go @@ -96,6 +96,14 @@ type Echo struct { // middleware are middlewares that are called after routing is done and before handler is called middleware []MiddlewareFunc + // chain is the global middleware chain (e.middleware) compiled once and reused for every request. + // It terminates in a dispatcher that invokes the route handler stored on the Context during routing. + // Rebuilt by Use(). See buildRouterChains. + chain HandlerFunc + // preChain is the pre-middleware chain (e.premiddleware) compiled once. It performs routing and then + // invokes chain. Rebuilt by Pre()/Use(). Only used when premiddleware is registered. + preChain HandlerFunc + contextPathParamAllocSize atomic.Int32 // formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm) @@ -347,9 +355,28 @@ func New() *Echo { e.contextPool.New = func() any { return newContext(nil, nil, e) } + e.buildRouterChains() return e } +// buildRouterChains compiles the global and pre-middleware chains once so that ServeHTTP does not have to +// re-wrap middleware closures on every request. It must be called whenever e.middleware or e.premiddleware +// changes (i.e. from Use/Pre). This is safe because middleware must not be mutated after the server starts. +func (e *Echo) buildRouterChains() { + // dispatch is the terminal of the global chain: it invokes the handler resolved during routing. + dispatch := func(c *Context) error { + return c.handler(c) + } + e.chain = applyMiddleware(dispatch, e.middleware...) + + // route performs routing (storing the matched handler on the Context) and then runs the global chain. + route := func(c *Context) error { + c.handler = e.router.Route(c) + return e.chain(c) + } + e.preChain = applyMiddleware(route, e.premiddleware...) +} + // NewContext returns a new Context instance. // // Note: both request and response can be left to nil as Echo.ServeHTTP will call c.Reset(req,resp) anyway @@ -425,11 +452,13 @@ func DefaultHTTPErrorHandler(exposeError bool) HTTPErrorHandler { // Meaning middleware is executed even for 404 (not found) cases. func (e *Echo) Pre(middleware ...MiddlewareFunc) { e.premiddleware = append(e.premiddleware, middleware...) + e.buildRouterChains() } // Use adds middleware to the chain which is run after router has found matching route and before route/request handler method is executed. func (e *Echo) Use(middleware ...MiddlewareFunc) { e.middleware = append(e.middleware, middleware...) + e.buildRouterChains() } // CONNECT registers a new CONNECT route for a path with matching handler in the @@ -702,20 +731,18 @@ func (e *Echo) serveHTTP(w http.ResponseWriter, r *http.Request) { defer e.contextPool.Put(c) c.Reset(r, w) - var h HandlerFunc + // The global (e.chain) and pre-middleware (e.preChain) chains are compiled once in buildRouterChains and + // reused here, so no middleware closures are allocated per request. + var err error if e.premiddleware == nil { - h = applyMiddleware(e.router.Route(c), e.middleware...) + c.handler = e.router.Route(c) + err = e.chain(c) } else { - h = func(cc *Context) error { - h1 := applyMiddleware(e.router.Route(cc), e.middleware...) - return h1(cc) - } - h = applyMiddleware(h, e.premiddleware...) + err = e.preChain(c) } - // Execute chain - if err := h(c); err != nil { + if err != nil { e.HTTPErrorHandler(c, err) } } diff --git a/middleware/randomstring_bench_test.go b/middleware/randomstring_bench_test.go new file mode 100644 index 000000000..d37d3dfd4 --- /dev/null +++ b/middleware/randomstring_bench_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package middleware + +import ( + "bufio" + "io" + "testing" +) + +// randomStringUnpooled is the previous (pre-pooling) implementation, kept here only to A/B benchmark +// against the current pooled randomString. +func randomStringUnpooled(length uint8) string { + reader := randomReaderPool.Get().(*bufio.Reader) + defer randomReaderPool.Put(reader) + + b := make([]byte, length) + r := make([]byte, length+(length/4)) + var i uint8 = 0 + for { + if _, err := io.ReadFull(reader, r); err != nil { + panic("unexpected error reading from crypto/rand") + } + for _, rb := range r { + if rb > randomStringMaxByte { + continue + } + b[i] = randomStringCharset[rb%randomStringCharsetLen] + i++ + if i == length { + return string(b) + } + } + } +} + +func BenchmarkRandomString_Unpooled(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = randomStringUnpooled(32) + } +} + +func BenchmarkRandomString_Pooled(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = randomString(32) + } +} diff --git a/middleware/randomstring_concurrent_test.go b/middleware/randomstring_concurrent_test.go new file mode 100644 index 000000000..c1efc31a5 --- /dev/null +++ b/middleware/randomstring_concurrent_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package middleware + +import ( + "strings" + "sync" + "testing" +) + +// TestRandomStringConcurrent guards the pooled scratch buffers in randomString: concurrent callers +// must not share/alias a buffer and corrupt each other's output. Run with -race. +func TestRandomStringConcurrent(t *testing.T) { + const goroutines, iterations = 100, 300 + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + s := randomString(32) + if len(s) != 32 { + t.Errorf("expected length 32, got %d (%q)", len(s), s) + return + } + for _, r := range s { + if !strings.ContainsRune(randomStringCharset, r) { + t.Errorf("char %q not in charset (%q)", r, s) + return + } + } + } + }() + } + wg.Wait() +} diff --git a/middleware/secure.go b/middleware/secure.go index bd389f7ae..022cce4a1 100644 --- a/middleware/secure.go +++ b/middleware/secure.go @@ -104,6 +104,20 @@ func (config SecureConfig) ToMiddleware() (echo.MiddlewareFunc, error) { config.Skipper = DefaultSecureConfig.Skipper } + // Precompute the Strict-Transport-Security header value once: it depends only on immutable config, + // so there is no need to rebuild it with fmt.Sprintf on every HTTPS request. Empty when HSTS is disabled. + hstsValue := "" + if config.HSTSMaxAge != 0 { + subdomains := "" + if !config.HSTSExcludeSubdomains { + subdomains = "; includeSubdomains" + } + if config.HSTSPreloadEnabled { + subdomains += "; preload" + } + hstsValue = fmt.Sprintf("max-age=%d%s", config.HSTSMaxAge, subdomains) + } + return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { if config.Skipper(c) { @@ -122,15 +136,8 @@ func (config SecureConfig) ToMiddleware() (echo.MiddlewareFunc, error) { if config.XFrameOptions != "" { res.Header().Set(echo.HeaderXFrameOptions, config.XFrameOptions) } - if (c.IsTLS() || (req.Header.Get(echo.HeaderXForwardedProto) == "https")) && config.HSTSMaxAge != 0 { - subdomains := "" - if !config.HSTSExcludeSubdomains { - subdomains = "; includeSubdomains" - } - if config.HSTSPreloadEnabled { - subdomains = fmt.Sprintf("%s; preload", subdomains) - } - res.Header().Set(echo.HeaderStrictTransportSecurity, fmt.Sprintf("max-age=%d%s", config.HSTSMaxAge, subdomains)) + if hstsValue != "" && (c.IsTLS() || (req.Header.Get(echo.HeaderXForwardedProto) == "https")) { + res.Header().Set(echo.HeaderStrictTransportSecurity, hstsValue) } if config.ContentSecurityPolicy != "" { if config.CSPReportOnly { diff --git a/middleware/util.go b/middleware/util.go index 3bae920f5..952d34242 100644 --- a/middleware/util.go +++ b/middleware/util.go @@ -50,12 +50,35 @@ const randomStringCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy const randomStringCharsetLen = 52 // len(randomStringCharset) const randomStringMaxByte = 255 - (256 % randomStringCharsetLen) +// randStringScratch holds the reusable working buffers for randomString so that ID generation does not +// allocate scratch slices on every request (only the returned string is allocated). The buffers grow to +// the largest requested length and are reused via randStringScratchPool. +type randStringScratch struct { + b []byte // output bytes (copied into the returned string) + r []byte // random source bytes read from crypto/rand +} + +var randStringScratchPool = sync.Pool{New: func() any { return new(randStringScratch) }} + func randomString(length uint8) string { reader := randomReaderPool.Get().(*bufio.Reader) defer randomReaderPool.Put(reader) + sc := randStringScratchPool.Get().(*randStringScratch) + defer randStringScratchPool.Put(sc) - b := make([]byte, length) - r := make([]byte, length+(length/4)) // perf: avoid read from rand.Reader many times + n := int(length) + if cap(sc.b) < n { + sc.b = make([]byte, n) + } else { + sc.b = sc.b[:n] + } + rlen := n + n/4 // perf: avoid read from rand.Reader many times + if cap(sc.r) < rlen { + sc.r = make([]byte, rlen) + } else { + sc.r = sc.r[:rlen] + } + b, r := sc.b, sc.r var i uint8 = 0 // security note: @@ -77,7 +100,7 @@ func randomString(length uint8) string { b[i] = randomStringCharset[rb%randomStringCharsetLen] i++ if i == length { - return string(b) + return string(b[:length]) } } } diff --git a/perf_bench_test.go b/perf_bench_test.go new file mode 100644 index 000000000..b6ff0f640 --- /dev/null +++ b/perf_bench_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// nopResponseWriter is a no-op http.ResponseWriter used to isolate framework +// overhead from the cost of httptest.ResponseRecorder buffering. +type nopResponseWriter struct{ h http.Header } + +func (w *nopResponseWriter) Header() http.Header { + if w.h == nil { + w.h = make(http.Header) + } + return w.h +} +func (w *nopResponseWriter) Write(b []byte) (int, error) { return len(b), nil } +func (w *nopResponseWriter) WriteHeader(int) {} + +func benchServe(b *testing.B, e *Echo, req *http.Request) { + b.Helper() + w := &nopResponseWriter{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + w.h = nil + e.ServeHTTP(w, req) + } +} + +func BenchmarkServeHTTP_Static(b *testing.B) { + e := New() + e.GET("/users/profile", func(c *Context) error { return c.NoContent(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/users/profile", nil) + benchServe(b, e, req) +} + +func BenchmarkServeHTTP_Param(b *testing.B) { + e := New() + e.GET("/users/:id/books/:bid", func(c *Context) error { return c.NoContent(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/users/42/books/7", nil) + benchServe(b, e, req) +} + +// Exercises the global middleware chain (finding #1). Five pass-through middlewares. +func BenchmarkServeHTTP_Middleware(b *testing.B) { + e := New() + for i := 0; i < 5; i++ { + e.Use(func(next HandlerFunc) HandlerFunc { + return func(c *Context) error { return next(c) } + }) + } + e.GET("/users/:id", func(c *Context) error { return c.NoContent(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/users/42", nil) + benchServe(b, e, req) +} + +func BenchmarkServeHTTP_String(b *testing.B) { + e := New() + e.GET("/", func(c *Context) error { return c.String(http.StatusOK, "Hello, World!") }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + benchServe(b, e, req) +} + +func BenchmarkServeHTTP_JSON(b *testing.B) { + e := New() + type payload struct { + ID int `json:"id"` + Name string `json:"name"` + Tags []string + } + p := payload{ID: 1, Name: "Jon Snow", Tags: []string{"a", "b", "c"}} + e.GET("/", func(c *Context) error { return c.JSON(http.StatusOK, p) }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + benchServe(b, e, req) +} + +// Exercises a per-request Set (as request_id/auth middleware do), measuring the store-map reuse. +func BenchmarkServeHTTP_Store(b *testing.B) { + e := New() + e.GET("/", func(c *Context) error { + c.Set("user", "alice") + return c.NoContent(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + benchServe(b, e, req) +} + +func BenchmarkContext_GetSet(b *testing.B) { + e := New() + c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), &nopResponseWriter{}) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Set("key", i) + _ = c.Get("key") + } +} + +type bindTarget struct { + ID int `json:"id" query:"id"` + Name string `json:"name" query:"name"` + Email string `json:"email" query:"email"` + Age int `json:"age" query:"age"` + Active bool `json:"active" query:"active"` +} + +func BenchmarkBind_JSON(b *testing.B) { + e := New() + body := `{"id":1,"name":"Jon Snow","email":"jon@winterfell.north","age":24,"active":true}` + e.POST("/", func(c *Context) error { + var t bindTarget + return c.Bind(&t) + }) + b.ReportAllocs() + b.ResetTimer() + w := &nopResponseWriter{} + for i := 0; i < b.N; i++ { + w.h = nil + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(HeaderContentType, MIMEApplicationJSON) + e.ServeHTTP(w, req) + } +} + +func BenchmarkBind_Query(b *testing.B) { + e := New() + e.GET("/", func(c *Context) error { + var t bindTarget + return c.Bind(&t) + }) + b.ReportAllocs() + b.ResetTimer() + w := &nopResponseWriter{} + req := httptest.NewRequest(http.MethodGet, "/?id=1&name=Jon&email=jon@x.io&age=24&active=true", nil) + for i := 0; i < b.N; i++ { + w.h = nil + e.ServeHTTP(w, req) + } +} diff --git a/query_fastpath_test.go b/query_fastpath_test.go new file mode 100644 index 000000000..ae4ac82e8 --- /dev/null +++ b/query_fastpath_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/url" + "testing" +) + +const benchQuery = "id=42&name=Jon&lang=en&page=2" + +// BenchmarkQueryParam_FastPath measures the single-key raw scan (current behavior). +func BenchmarkQueryParam_FastPath(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = getRawQueryParam(benchQuery, "name") + } +} + +// BenchmarkQueryParam_Map measures the previous behavior: build the full url.Values then Get. +func BenchmarkQueryParam_Map(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + v, _ := url.ParseQuery(benchQuery) + _ = v.Get("name") + } +} + +// TestGetRawQueryParamMatchesStdlib asserts the single-key fast path returns exactly what +// url.Values.Get (over url.ParseQuery output) would return, across a battery of edge cases. +func TestGetRawQueryParamMatchesStdlib(t *testing.T) { + queries := []string{ + "", + "a=1", + "a=1&b=2", + "a=1&a=2", // first match wins + "a=&b=2", // empty value + "=v", // empty key + "a", // key without '=' + "a=x+y", // '+' -> space + "a=%20space", // percent escape + "a=%2", // bad value escape -> pair skipped + "a=%ZZ&a=2", // bad escape on first match -> skip, fall through to second + "%ZZ=1&a=2", // bad key escape -> pair skipped + "a%3Db=c", // encoded key 'a=b' + "a=1;b=2", // semicolon segment skipped entirely + "a=1&c=3;d=4&e=5", + "name=Jon&name=Snow&age=24", + "q=%E4%B8%AD%E6%96%87", // unicode value + "empty=&x=1", + "a=1&&b=2", // empty segment + } + names := []string{"a", "b", "c", "d", "e", "name", "age", "q", "empty", "x", "a=b", "missing", ""} + + for _, q := range queries { + want, _ := url.ParseQuery(q) // url.Values; mirrors URL.Query() (error ignored) + for _, name := range names { + got := getRawQueryParam(q, name) + exp := want.Get(name) + if got != exp { + t.Errorf("getRawQueryParam(%q, %q) = %q; url.Values.Get = %q", q, name, got, exp) + } + } + } +} From c8a1a9360b58be117fae62da40c177c47ed2387b Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 13 Jun 2026 18:05:49 -0700 Subject: [PATCH 2/3] test: de-flake TestStartConfig_WithListenerNetwork (use ephemeral port) The 4 sub-tests reused a hard-coded port (1323) sequentially; the next bind raced with the prior server's shutdown/socket release and failed on CI. Use :0 and dial the address reported by ListenerAddrFunc, preserving the network-family (tcp/tcp4/tcp6) intent without the fixed-port race. Co-Authored-By: Claude Opus 4.8 (1M context) --- server_test.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server_test.go b/server_test.go index 7fa5b3cba..3e717c822 100644 --- a/server_test.go +++ b/server_test.go @@ -416,24 +416,27 @@ func TestStartConfig_WithListenerNetwork(t *testing.T) { { name: "tcp ipv4 address", network: "tcp", - address: "127.0.0.1:1323", + address: "127.0.0.1:0", }, { name: "tcp ipv6 address", network: "tcp", - address: "[::1]:1323", + address: "[::1]:0", }, { name: "tcp4 ipv4 address", network: "tcp4", - address: "127.0.0.1:1323", + address: "127.0.0.1:0", }, { name: "tcp6 ipv6 address", network: "tcp6", - address: "[::1]:1323", + address: "[::1]:0", }, } + // Use an ephemeral port (:0) and dial the actual bound address reported by ListenerAddrFunc, rather + // than a hard-coded port. A fixed port reused across these sequential sub-tests races with the prior + // server's shutdown/socket release and flakes on CI. hasIPv6 := supportsIPv6() for _, tc := range testCases { @@ -465,10 +468,10 @@ func TestStartConfig_WithListenerNetwork(t *testing.T) { errCh <- s.Start(ctx, e) }() - _, err := waitForServerStart(addrChan, errCh) + boundAddr, err := waitForServerStart(addrChan, errCh) assert.NoError(t, err) - code, body, err := doGet(fmt.Sprintf("http://%s/ok", tc.address)) + code, body, err := doGet(fmt.Sprintf("http://%s/ok", boundAddr)) assert.NoError(t, err) assert.Equal(t, http.StatusOK, code) assert.Equal(t, "OK", body) From fbf15bdb1c258158cf10b06cc19848115651ddfe Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 13 Jun 2026 18:20:20 -0700 Subject: [PATCH 3/3] review: fix stale Reset comment, document dsw self-reference invariant, test cached bind errors - context: correct newContext comment (Reset clears the store map, no longer nils it); document that only json()'s nested-guard may point the response at &c.dsw - test: deterministic cold-then-warm bind ensures the per-type cache preserves field-name conversion errors regardless of suite ordering Co-Authored-By: Claude Opus 4.8 (1M context) --- bind_cache_test.go | 31 +++++++++++++++++++++++++++++++ context.go | 6 ++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 bind_cache_test.go diff --git a/bind_cache_test.go b/bind_cache_test.go new file mode 100644 index 000000000..f3da8a13e --- /dev/null +++ b/bind_cache_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestBindCachedMetaPreservesFieldNameError ensures the per-type bind metadata cache preserves the +// field-name prefix in conversion errors on BOTH the cold (first) and warm (cached) bind of a type. +// DTO is declared locally so its reflect.Type is independent of suite ordering, making the second +// bind a deterministic cache hit (the bindMetaFor Load branch). +func TestBindCachedMetaPreservesFieldNameError(t *testing.T) { + type DTO struct { + Number int `query:"number"` + } + bind := func() error { + e := New() + req := httptest.NewRequest(http.MethodGet, "/?number=10a", nil) + var dto DTO + return e.NewContext(req, httptest.NewRecorder()).Bind(&dto) + } + + assert.ErrorContains(t, bind(), "number", "cold cache: error must carry field name") + assert.ErrorContains(t, bind(), "number", "warm cache: error must still carry field name") +} diff --git a/context.go b/context.go index 471d6e2e1..9a7429f39 100644 --- a/context.go +++ b/context.go @@ -78,7 +78,9 @@ type Context struct { handler HandlerFunc // dsw is reused by json() so that each JSON response does not heap-allocate a delayedStatusWriter. - // It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. + // It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. Only json() may point + // the response at &c.dsw, and only via the nested-call guard there — aliasing it to itself (wrapping + // &c.dsw around &c.dsw) would make the response writer reference itself. dsw delayedStatusWriter store map[string]any @@ -105,7 +107,7 @@ func NewContext(r *http.Request, w http.ResponseWriter, opts ...any) *Context { } func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context { - // store is created lazily by Set (and reset to nil by Reset), so we deliberately do not allocate a map here. + // store is created lazily by Set and cleared (not freed) by Reset, so we deliberately do not allocate a map here. c := &Context{ pathValues: nil, echo: e,