Skip to content

Commit 96d6496

Browse files
vishrclaude
andcommitted
review: fix nested c.JSON writer reuse, unexport sonic API, add pooling/dispatch tests
Addresses PR review findings: - context: guard delayedStatusWriter reuse against re-entrant c.JSON (avoid self-reference); document the unsafe stringToBytes contract (string lifetime + no-retain by wrapping writers) - sonic: unexport Serializer.API (constructor-only; nil-safe via cfg()) before release - tests: store no-leak across Reset, JSON status across Reset, nested c.JSON, global/pre middleware on 404/405/OPTIONS, randomString concurrency (-race), sonic bad-JSON + indent + zero-value serializer, query fast-path malformed-escape cases - docs: keep-in-sync notes on bind tag cache; stamp sonic benchmark date/version Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa98249 commit 96d6496

8 files changed

Lines changed: 244 additions & 23 deletions

File tree

bind.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,21 @@ func (b *DefaultBinder) Bind(c *Context, target any) error {
141141
// once per struct type and reuse it across requests (see bindStructMeta). Only type-level data is cached here;
142142
// per-request, per-instance reflect.Value operations still happen in bindData.
143143
type bindFieldMeta struct {
144-
index int // field index within the struct
145-
anonymous bool // reflect.StructField.Anonymous
146-
fieldKind reflect.Kind // declared field type kind (typeField.Type.Kind())
147-
formatTag string // value of the `format` struct tag
148-
// binding-source tag values; bindData is only ever called with one of these four tags.
144+
index int // field index within the struct
145+
// fieldKind is the DECLARED field kind (typeField.Type.Kind()), used only for unmarshal dispatch.
146+
// It is intentionally not the post-anonymous-pointer-deref live kind; bindData computes that
147+
// separately as structFieldKind where needed.
148+
fieldKind reflect.Kind
149+
anonymous bool // reflect.StructField.Anonymous
150+
formatTag string // value of the `format` struct tag
151+
// binding-source tag values. bindData is only ever called with one of these four tags (see the
152+
// callers BindPathValues/BindQueryParams/BindBody/BindHeaders). Keep these fields, the four
153+
// f.Tag.Get(...) lines in bindMetaFor, and the tagName switch in sync if a source is ever added.
149154
param, query, form, header string
150155
}
151156

152157
// tagName returns the field's tag value for the given binding source tag.
158+
// Keep in sync with the tag fields above and the f.Tag.Get calls in bindMetaFor.
153159
func (m *bindFieldMeta) tagName(tag string) string {
154160
switch tag {
155161
case "param":

context.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ import (
2222
"unsafe"
2323
)
2424

25-
// stringToBytes returns a []byte view over s without copying. The result MUST only be passed to APIs that
26-
// treat the slice as read-only and do not retain it (e.g. http.ResponseWriter.Write, which copies as needed).
27-
// This avoids the allocation+copy of []byte(s) on the response write path. See findings on zero-copy writes
28-
// used by fasthttp/fiber. Writing through the returned slice is undefined behavior.
25+
// stringToBytes returns a []byte view over s without copying, avoiding the allocation+copy of []byte(s)
26+
// on the response write path (the zero-copy technique used by fasthttp/fiber).
27+
//
28+
// Contract — all of the following must hold at every call site:
29+
// - The result is read-only: writing through it is undefined behaviour.
30+
// - The callee must NOT retain the slice beyond the call. It is only passed to the response
31+
// Writer's Write, whose io.Writer contract forbids retaining/mutating the argument. Note the
32+
// concrete writer may be a wrapping ResponseWriter (e.g. gzip); such writers must copy, not alias.
33+
// - s must stay reachable for as long as the slice is used: the slice aliases s's backing array.
2934
func stringToBytes(s string) []byte {
3035
if s == "" {
3136
return nil
@@ -543,9 +548,16 @@ func (c *Context) json(code int, i any, indent string) error {
543548
// (global) error handler decides correct status code for the error to be sent to the client.
544549
// For that we need to use writer that can store the proposed status code until the first Write is called.
545550
resp := c.Response()
546-
// Reuse the Context-owned delayedStatusWriter instead of heap-allocating one per JSON response.
547-
c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code}
548-
c.SetResponse(&c.dsw)
551+
// Reuse the Context-owned delayedStatusWriter to avoid heap-allocating one per JSON response.
552+
// If we are already nested inside a delayed write (rare: a serializer or handler calling c.JSON
553+
// re-entrantly), allocate a fresh writer so the outer call's writer (which is &c.dsw) is not
554+
// clobbered — reusing c.dsw here would make it reference itself.
555+
if _, nested := resp.(*delayedStatusWriter); nested {
556+
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
557+
} else {
558+
c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code}
559+
c.SetResponse(&c.dsw)
560+
}
549561
defer c.SetResponse(resp)
550562

551563
return c.echo.JSONSerializer.Serialize(c, i, indent)

dispatch_pool_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
// TestContextResetClearsStore guards the clear(c.store) reuse: a pooled Context must not leak store
15+
// values from a previous request into the next one, and Set must still work after a clear-based Reset.
16+
func TestContextResetClearsStore(t *testing.T) {
17+
e := New()
18+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
19+
c.Set("secret", "req1")
20+
assert.Equal(t, "req1", c.Get("secret"))
21+
22+
c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
23+
assert.Nil(t, c.Get("secret"), "store must not leak across Reset")
24+
25+
c.Set("k", "req2") // Set must still work after clear-based reset
26+
assert.Equal(t, "req2", c.Get("k"))
27+
}
28+
29+
// TestContextJSONStatusAcrossReset guards the reused delayedStatusWriter (c.dsw): a second JSON
30+
// response on a pooled+Reset Context must use the new status, not inherit the previous one.
31+
func TestContextJSONStatusAcrossReset(t *testing.T) {
32+
e := New()
33+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
34+
assert.NoError(t, c.JSON(http.StatusTeapot, map[string]int{"a": 1}))
35+
36+
rec2 := httptest.NewRecorder()
37+
c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), rec2)
38+
assert.NoError(t, c.JSON(http.StatusCreated, map[string]int{"b": 2}))
39+
assert.Equal(t, http.StatusCreated, rec2.Code)
40+
assert.JSONEq(t, `{"b":2}`, rec2.Body.String())
41+
}
42+
43+
// TestNestedJSONUsesFreshDelayedWriter guards the nested c.JSON case: a serializer that calls c.JSON
44+
// re-entrantly must not corrupt the outer delayedStatusWriter (regression test for c.dsw self-reference).
45+
func TestNestedJSONUsesFreshDelayedWriter(t *testing.T) {
46+
e := New()
47+
e.JSONSerializer = nestedJSONSerializer{}
48+
rec := httptest.NewRecorder()
49+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec)
50+
assert.NoError(t, c.JSON(http.StatusOK, map[string]int{"outer": 1}))
51+
assert.Equal(t, http.StatusOK, rec.Code)
52+
}
53+
54+
type nestedJSONSerializer struct{}
55+
56+
func (nestedJSONSerializer) Serialize(c *Context, i any, indent string) error {
57+
if m, ok := i.(map[string]int); ok && m["outer"] == 1 {
58+
// re-enter c.JSON once before encoding the outer payload
59+
if err := c.JSON(http.StatusOK, map[string]int{"inner": 2}); err != nil {
60+
return err
61+
}
62+
}
63+
return (DefaultJSONSerializer{}).Serialize(c, i, indent)
64+
}
65+
66+
func (nestedJSONSerializer) Deserialize(c *Context, i any) error {
67+
return (DefaultJSONSerializer{}).Deserialize(c, i)
68+
}
69+
70+
// TestGlobalMiddlewareRunsOnNotFoundAndMethodNotAllowed pins the dispatch contract: global (Use) and
71+
// pre (Pre) middleware must execute even when the router returns 404 / 405 / OPTIONS handlers.
72+
func TestGlobalMiddlewareRunsOnNotFoundAndMethodNotAllowed(t *testing.T) {
73+
cases := []struct {
74+
name, method, path string
75+
code int
76+
}{
77+
{"404", http.MethodGet, "/missing", http.StatusNotFound},
78+
{"405", http.MethodPost, "/", http.StatusMethodNotAllowed},
79+
{"OPTIONS", http.MethodOptions, "/", http.StatusNoContent},
80+
}
81+
for _, tc := range cases {
82+
t.Run(tc.name, func(t *testing.T) {
83+
e := New()
84+
var pre, use bool
85+
e.Pre(func(n HandlerFunc) HandlerFunc {
86+
return func(c *Context) error { pre = true; return n(c) }
87+
})
88+
e.Use(func(n HandlerFunc) HandlerFunc {
89+
return func(c *Context) error { use = true; return n(c) }
90+
})
91+
e.GET("/", func(c *Context) error { return c.String(http.StatusOK, "ok") })
92+
93+
rec := httptest.NewRecorder()
94+
e.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
95+
96+
assert.True(t, pre, "pre-middleware must run on %s", tc.name)
97+
assert.True(t, use, "global middleware must run on %s", tc.name)
98+
assert.Equal(t, tc.code, rec.Code)
99+
})
100+
}
101+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package middleware
5+
6+
import (
7+
"strings"
8+
"sync"
9+
"testing"
10+
)
11+
12+
// TestRandomStringConcurrent guards the pooled scratch buffers in randomString: concurrent callers
13+
// must not share/alias a buffer and corrupt each other's output. Run with -race.
14+
func TestRandomStringConcurrent(t *testing.T) {
15+
const goroutines, iterations = 100, 300
16+
var wg sync.WaitGroup
17+
wg.Add(goroutines)
18+
for g := 0; g < goroutines; g++ {
19+
go func() {
20+
defer wg.Done()
21+
for i := 0; i < iterations; i++ {
22+
s := randomString(32)
23+
if len(s) != 32 {
24+
t.Errorf("expected length 32, got %d (%q)", len(s), s)
25+
return
26+
}
27+
for _, r := range s {
28+
if !strings.ContainsRune(randomStringCharset, r) {
29+
t.Errorf("char %q not in charset (%q)", r, s)
30+
return
31+
}
32+
}
33+
}
34+
}()
35+
}
36+
wg.Wait()
37+
}

query_fastpath_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func TestGetRawQueryParamMatchesStdlib(t *testing.T) {
4141
"a=x+y", // '+' -> space
4242
"a=%20space", // percent escape
4343
"a=%2", // bad value escape -> pair skipped
44+
"a=%ZZ&a=2", // bad escape on first match -> skip, fall through to second
45+
"%ZZ=1&a=2", // bad key escape -> pair skipped
4446
"a%3Db=c", // encoded key 'a=b'
4547
"a=1;b=2", // semicolon segment skipped entirely
4648
"a=1&c=3;d=4&e=5",

sonic/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ workload, but as a rule of thumb:
5555

5656
### Benchmarks
5757

58-
End-to-end through `ServeHTTP`, Apple M3 Max (arm64), Go 1.26, representative payload
59-
(struct with strings, ints, bool, slices, floats and a map):
58+
End-to-end through `ServeHTTP`, Apple M3 Max (arm64), Go 1.26, sonic v1.15.2 (measured 2026-06),
59+
representative payload (struct with strings, ints, bool, slices, floats and a map). Absolute numbers
60+
are illustrative and will drift across Go/sonic versions and hardware — the **direction** is the
61+
durable takeaway:
6062

6163
| Operation | `encoding/json` | sonic | Δ time | Δ allocs |
6264
|---|--:|--:|--:|--:|

sonic/sonic.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,33 @@ import (
2525
"github.com/labstack/echo/v5"
2626
)
2727

28-
// Serializer implements echo.JSONSerializer using the sonic library.
28+
// Serializer implements echo.JSONSerializer using the sonic library. Construct it with New or
29+
// NewWithAPI; the underlying sonic configuration is intentionally not an exported field so the
30+
// serializer is safe for concurrent use and cannot be misconfigured to a nil config.
2931
type Serializer struct {
30-
// API is the frozen sonic configuration used for encoding/decoding.
31-
// Defaults to sonic.ConfigDefault when created via New.
32-
API sonic.API
32+
// api is the frozen sonic configuration used for encoding/decoding. A frozen sonic.API is safe
33+
// for concurrent use, which echo.JSONSerializer requires.
34+
api sonic.API
3335
}
3436

3537
// New returns a Serializer using sonic's default configuration.
3638
func New() *Serializer {
37-
return &Serializer{API: sonic.ConfigDefault}
39+
return &Serializer{api: sonic.ConfigDefault}
3840
}
3941

4042
// NewWithAPI returns a Serializer using the provided sonic configuration, e.g.
4143
// sonic.ConfigStd (encoding/json compatible) or sonic.ConfigFastest.
4244
func NewWithAPI(api sonic.API) *Serializer {
43-
return &Serializer{API: api}
45+
return &Serializer{api: api}
46+
}
47+
48+
// cfg returns the configured sonic API, falling back to sonic.ConfigDefault so a zero-value
49+
// Serializer (constructed without New/NewWithAPI) does not panic.
50+
func (s *Serializer) cfg() sonic.API {
51+
if s.api == nil {
52+
return sonic.ConfigDefault
53+
}
54+
return s.api
4455
}
4556

4657
// Serialize converts target into JSON and writes it to the response.
@@ -55,9 +66,9 @@ func (s *Serializer) Serialize(c *echo.Context, target any, indent string) error
5566
err error
5667
)
5768
if indent != "" {
58-
buf, err = s.API.MarshalIndent(target, "", indent)
69+
buf, err = s.cfg().MarshalIndent(target, "", indent)
5970
} else {
60-
buf, err = s.API.Marshal(target)
71+
buf, err = s.cfg().Marshal(target)
6172
}
6273
if err != nil {
6374
return err
@@ -68,7 +79,7 @@ func (s *Serializer) Serialize(c *echo.Context, target any, indent string) error
6879

6980
// Deserialize reads JSON from the request body and stores it in target.
7081
func (s *Serializer) Deserialize(c *echo.Context, target any) error {
71-
if err := s.API.NewDecoder(c.Request().Body).Decode(target); err != nil {
82+
if err := s.cfg().NewDecoder(c.Request().Body).Decode(target); err != nil {
7283
return echo.ErrBadRequest.Wrap(err)
7384
}
7485
return nil

sonic/sonic_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package sonic
55

66
import (
77
"bytes"
8+
"errors"
89
"io"
910
"net/http"
1011
"net/http/httptest"
@@ -82,6 +83,55 @@ func TestSerializerRoundTrip(t *testing.T) {
8283
}
8384
}
8485

86+
// TestDeserializeBadJSON verifies malformed input is mapped to a 400 echo.HTTPError, matching the
87+
// default serializer's contract that BindBody relies on.
88+
func TestDeserializeBadJSON(t *testing.T) {
89+
e := echo.New()
90+
c := e.NewContext(httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte("{bad"))), httptest.NewRecorder())
91+
var out payload
92+
err := New().Deserialize(c, &out)
93+
if err == nil {
94+
t.Fatal("expected error for malformed JSON")
95+
}
96+
var he *echo.HTTPError
97+
if !errors.As(err, &he) {
98+
t.Fatalf("expected *echo.HTTPError, got %T", err)
99+
}
100+
if he.Code != http.StatusBadRequest {
101+
t.Fatalf("expected 400, got %d", he.Code)
102+
}
103+
}
104+
105+
// TestSerializeIndent exercises the indent (MarshalIndent) branch used by c.JSONPretty.
106+
func TestSerializeIndent(t *testing.T) {
107+
e := echo.New()
108+
rec := httptest.NewRecorder()
109+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec)
110+
if err := New().Serialize(c, sample(), " "); err != nil {
111+
t.Fatalf("Serialize with indent: %v", err)
112+
}
113+
if !bytes.Contains(rec.Body.Bytes(), []byte("\n ")) {
114+
t.Fatalf("expected indented output, got %q", rec.Body.String())
115+
}
116+
var out payload
117+
if err := New().Deserialize(e.NewContext(httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(rec.Body.Bytes())), httptest.NewRecorder()), &out); err != nil {
118+
t.Fatalf("indented output did not round-trip: %v", err)
119+
}
120+
if !reflect.DeepEqual(sample(), out) {
121+
t.Fatalf("indent round trip mismatch: %#v", out)
122+
}
123+
}
124+
125+
// TestZeroValueSerializer ensures a zero-value Serializer (no constructor) does not panic.
126+
func TestZeroValueSerializer(t *testing.T) {
127+
e := echo.New()
128+
rec := httptest.NewRecorder()
129+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec)
130+
if err := (&Serializer{}).Serialize(c, sample(), ""); err != nil {
131+
t.Fatalf("zero-value Serialize: %v", err)
132+
}
133+
}
134+
85135
func benchSerialize(b *testing.B, s echo.JSONSerializer) {
86136
e := echo.New()
87137
in := sample()

0 commit comments

Comments
 (0)