From bd84804074b72ba183e36b05a5d727205f3a862d Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 15 Jun 2026 17:32:57 -0700 Subject: [PATCH 01/10] Added gzip handler for rpc --- sei-cosmos/server/api/server.go | 4 +- sei-tendermint/internal/rpc/core/env.go | 1 + sei-tendermint/rpc/jsonrpc/server/gzip.go | 116 ++++++++++++++++++ .../rpc/jsonrpc/server/gzip_test.go | 98 +++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 sei-tendermint/rpc/jsonrpc/server/gzip.go create mode 100644 sei-tendermint/rpc/jsonrpc/server/gzip_test.go diff --git a/sei-cosmos/server/api/server.go b/sei-cosmos/server/api/server.go index c1ebf09c70..274e81dad4 100644 --- a/sei-cosmos/server/api/server.go +++ b/sei-cosmos/server/api/server.go @@ -116,12 +116,12 @@ func (s *Server) Start(cfg config.Config, apiMetrics *telemetry.Metrics) error { if cfg.API.EnableUnsafeCORS { allowAllCORS := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"})) s.mtx.Unlock() - return tmrpcserver.Serve(context.Background(), s.listener, allowAllCORS(h), tmCfg) + return tmrpcserver.Serve(context.Background(), s.listener, tmrpcserver.NewGzipHandler(allowAllCORS(h)), tmCfg) } logger.Info("starting API server...") s.mtx.Unlock() - return tmrpcserver.Serve(context.Background(), s.listener, s.Router, tmCfg) + return tmrpcserver.Serve(context.Background(), s.listener, tmrpcserver.NewGzipHandler(s.Router), tmCfg) } // Close closes the API server. diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 9d40894b95..c346c2abf9 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -361,6 +361,7 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( }) rootHandler = corsMiddleware.Handler(mux) } + rootHandler = rpcserver.NewGzipHandler(rootHandler) if conf.RPC.IsTLSEnabled() { go func() { if err := rpcserver.ServeTLS( diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go new file mode 100644 index 0000000000..6e5443b66d --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -0,0 +1,116 @@ +package server + +import ( + "compress/gzip" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +var gzPool = sync.Pool{ + New: func() any { + w := gzip.NewWriter(io.Discard) + return w + }, +} + +type gzipResponseWriter struct { + resp http.ResponseWriter + + gz *gzip.Writer + contentLength uint64 + written uint64 + hasLength bool + inited bool +} + +func (w *gzipResponseWriter) init() { + if w.inited { + return + } + w.inited = true + + hdr := w.resp.Header() + length := hdr.Get("content-length") + if len(length) > 0 { + if n, err := strconv.ParseUint(length, 10, 64); err == nil { + w.hasLength = true + w.contentLength = n + } + } + + // Transfer-Encoding: identity signals that the inner handler wants to opt + // out of compression (e.g. error responses near the write deadline). + if hdr.Get("transfer-encoding") == "identity" { + return + } + + w.gz = gzPool.Get().(*gzip.Writer) + w.gz.Reset(w.resp) + hdr.Del("content-length") + hdr.Set("content-encoding", "gzip") +} + +func (w *gzipResponseWriter) Header() http.Header { + return w.resp.Header() +} + +func (w *gzipResponseWriter) WriteHeader(status int) { + w.init() + w.resp.WriteHeader(status) +} + +func (w *gzipResponseWriter) Write(b []byte) (int, error) { + w.init() + + if w.gz == nil { + return w.resp.Write(b) + } + + n, err := w.gz.Write(b) + w.written += uint64(n) //nolint:gosec + if w.hasLength && w.written >= w.contentLength { + err = w.gz.Close() + gzPool.Put(w.gz) + w.gz = nil + } + return n, err +} + +func (w *gzipResponseWriter) Flush() { + if w.gz != nil { + _ = w.gz.Flush() + } + if f, ok := w.resp.(http.Flusher); ok { + f.Flush() + } +} + +func (w *gzipResponseWriter) close() { + if w.gz == nil { + return + } + _ = w.gz.Close() + gzPool.Put(w.gz) + w.gz = nil +} + +// NewGzipHandler wraps next with gzip response compression. Compression is +// skipped for clients that do not advertise gzip support via Accept-Encoding +// and for WebSocket upgrade requests, preserving the http.Hijacker interface. +func NewGzipHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || + strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + next.ServeHTTP(w, r) + return + } + + wrapper := &gzipResponseWriter{resp: w} + defer wrapper.close() + + next.ServeHTTP(wrapper, r) + }) +} diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go new file mode 100644 index 0000000000..eca536798e --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "bufio" + "compress/gzip" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewGzipHandler_CompressesWhenAccepted(t *testing.T) { + body := strings.Repeat("hello world ", 100) + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected Content-Encoding: gzip, got %q", got) + } + r, err := gzip.NewReader(rr.Body) + if err != nil { + t.Fatalf("response body is not valid gzip: %v", err) + } + decoded, err := io.ReadAll(r) + if err != nil { + t.Fatalf("gzip decode error: %v", err) + } + if string(decoded) != body { + t.Fatalf("decoded body mismatch") + } + if rr.Body.Len() >= len(body) { + t.Fatalf("compressed size (%d) not smaller than original (%d)", rr.Body.Len(), len(body)) + } +} + +func TestNewGzipHandler_PassthroughWithoutAcceptEncoding(t *testing.T) { + body := `{"jsonrpc":"2.0","id":1}` + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no Content-Encoding, got %q", got) + } + if rr.Body.String() != body { + t.Fatalf("body mismatch: %q", rr.Body.String()) + } +} + +func TestNewGzipHandler_WebSocketPassthrough(t *testing.T) { + hijackCalled := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the original ResponseWriter is passed through (Hijacker accessible). + if _, ok := w.(http.Hijacker); ok { + hijackCalled = true + } + }) + + req := httptest.NewRequest(http.MethodGet, "/websocket", nil) + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + + rr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} + NewGzipHandler(inner).ServeHTTP(rr, req) + + if rr.Header().Get("Content-Encoding") != "" { + t.Fatal("gzip handler must not compress WebSocket upgrade requests") + } + if !hijackCalled { + t.Fatal("http.Hijacker must be accessible for WebSocket upgrade requests") + } +} + +// hijackableRecorder embeds httptest.ResponseRecorder and implements http.Hijacker +// to simulate a real connection that supports WebSocket upgrade. +type hijackableRecorder struct { + *httptest.ResponseRecorder +} + +func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return nil, nil, nil +} From 74db3f92aef2245b6511db646a3b27dd541615fa Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 15 Jun 2026 17:49:03 -0700 Subject: [PATCH 02/10] Addressing feedback --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 52 +++++++- .../rpc/jsonrpc/server/gzip_test.go | 122 +++++++++++++++++- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index 6e5443b66d..08045c5021 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -51,6 +51,7 @@ func (w *gzipResponseWriter) init() { w.gz.Reset(w.resp) hdr.Del("content-length") hdr.Set("content-encoding", "gzip") + hdr.Add("vary", "Accept-Encoding") } func (w *gzipResponseWriter) Header() http.Header { @@ -58,6 +59,14 @@ func (w *gzipResponseWriter) Header() http.Header { } func (w *gzipResponseWriter) WriteHeader(status int) { + // Bodyless responses must not be compressed — gzip would write a stream + // terminator into what must be an empty body (RFC 7230 §3.3). + if status == http.StatusNoContent || status == http.StatusNotModified || + (status >= 100 && status <= 199) { + w.inited = true // skip gzip setup + w.resp.WriteHeader(status) + return + } w.init() w.resp.WriteHeader(status) } @@ -72,7 +81,9 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { n, err := w.gz.Write(b) w.written += uint64(n) //nolint:gosec if w.hasLength && w.written >= w.contentLength { - err = w.gz.Close() + if cerr := w.gz.Close(); cerr != nil && err == nil { + err = cerr + } gzPool.Put(w.gz) w.gz = nil } @@ -97,13 +108,48 @@ func (w *gzipResponseWriter) close() { w.gz = nil } +// acceptsGzip reports whether the request advertises gzip encoding support, +// respecting q-values and the "*" wildcard per RFC 7231 §5.3.4. +func acceptsGzip(r *http.Request) bool { + gzipQ := -1.0 + starQ := -1.0 + for _, field := range r.Header["Accept-Encoding"] { + for part := range strings.SplitSeq(field, ",") { + part = strings.TrimSpace(part) + coding, params, _ := strings.Cut(part, ";") + coding = strings.ToLower(strings.TrimSpace(coding)) + q := 1.0 + for p := range strings.SplitSeq(params, ";") { + p = strings.TrimSpace(p) + if k, v, ok := strings.Cut(p, "="); ok && strings.ToLower(strings.TrimSpace(k)) == "q" { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + q = f + } + } + } + switch coding { + case "gzip": + gzipQ = q + case "*": + starQ = q + } + } + } + if gzipQ >= 0 { + return gzipQ > 0 + } + if starQ >= 0 { + return starQ > 0 + } + return false +} + // NewGzipHandler wraps next with gzip response compression. Compression is // skipped for clients that do not advertise gzip support via Accept-Encoding // and for WebSocket upgrade requests, preserving the http.Hijacker interface. func NewGzipHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || - strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + if !acceptsGzip(r) || strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { next.ServeHTTP(w, r) return } diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go index eca536798e..5f1a9cbb4f 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "strconv" "strings" "testing" ) @@ -27,6 +28,9 @@ func TestNewGzipHandler_CompressesWhenAccepted(t *testing.T) { if got := rr.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("expected Content-Encoding: gzip, got %q", got) } + if got := rr.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + t.Fatalf("expected Vary to contain Accept-Encoding, got %q", got) + } r, err := gzip.NewReader(rr.Body) if err != nil { t.Fatalf("response body is not valid gzip: %v", err) @@ -65,7 +69,6 @@ func TestNewGzipHandler_PassthroughWithoutAcceptEncoding(t *testing.T) { func TestNewGzipHandler_WebSocketPassthrough(t *testing.T) { hijackCalled := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify the original ResponseWriter is passed through (Hijacker accessible). if _, ok := w.(http.Hijacker); ok { hijackCalled = true } @@ -76,6 +79,8 @@ func TestNewGzipHandler_WebSocketPassthrough(t *testing.T) { req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") + // hijackableRecorder implements http.Hijacker to simulate a real conn; + // Hijack() returns nils because the test only checks the interface assertion. rr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} NewGzipHandler(inner).ServeHTTP(rr, req) @@ -87,8 +92,119 @@ func TestNewGzipHandler_WebSocketPassthrough(t *testing.T) { } } -// hijackableRecorder embeds httptest.ResponseRecorder and implements http.Hijacker -// to simulate a real connection that supports WebSocket upgrade. +func TestNewGzipHandler_ContentLengthEarlyClose(t *testing.T) { + body := strings.Repeat("hello world ", 100) + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected Content-Encoding: gzip, got %q", got) + } + // Content-Length must be removed since the compressed length differs. + if got := rr.Header().Get("Content-Length"); got != "" { + t.Fatalf("expected Content-Length to be stripped, got %q", got) + } + gr, err := gzip.NewReader(rr.Body) + if err != nil { + t.Fatalf("response body is not valid gzip: %v", err) + } + decoded, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("gzip decode error: %v", err) + } + if string(decoded) != body { + t.Fatalf("decoded body mismatch") + } +} + +func TestNewGzipHandler_TransferEncodingIdentityOptOut(t *testing.T) { + body := `{"error":"deadline exceeded"}` + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Transfer-Encoding", "identity") + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no Content-Encoding when Transfer-Encoding: identity, got %q", got) + } + if rr.Body.String() != body { + t.Fatalf("body mismatch: %q", rr.Body.String()) + } +} + +func TestNewGzipHandler_NoBodyFor204(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + if rr.Body.Len() != 0 { + t.Fatalf("expected empty body for 204, got %d bytes", rr.Body.Len()) + } + if got := rr.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no Content-Encoding for 204, got %q", got) + } +} + +func TestAcceptsGzip_QValueZero(t *testing.T) { + body := `{"jsonrpc":"2.0","id":1}` + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no compression for gzip;q=0, got Content-Encoding: %q", got) + } + if rr.Body.String() != body { + t.Fatalf("body mismatch: %q", rr.Body.String()) + } +} + +func TestAcceptsGzip_Wildcard(t *testing.T) { + body := strings.Repeat("hello world ", 100) + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "*") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected gzip for Accept-Encoding: *, got %q", got) + } +} + type hijackableRecorder struct { *httptest.ResponseRecorder } From 7b6f87b3e7c74e00134ddb828c5b64e79dc0d326 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 15 Jun 2026 17:55:01 -0700 Subject: [PATCH 03/10] Fixed issue with inspect RPC server --- sei-tendermint/internal/inspect/rpc/rpc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index 39beeee3f8..6ee044ce43 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -74,7 +74,7 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler { if rpcConfig.IsCorsEnabled() { rootHandler = addCORSHandler(rpcConfig, mux) } - return rootHandler + return server.NewGzipHandler(rootHandler) } func addCORSHandler(rpcConfig *config.RPCConfig, h http.Handler) http.Handler { From 84d4c4f87e9a6cc36af9eb9268ac01dfb5c0f3fc Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 15 Jun 2026 17:58:04 -0700 Subject: [PATCH 04/10] Added test coverage --- .../rpc/jsonrpc/server/gzip_test.go | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go index 5f1a9cbb4f..b1756718e8 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go @@ -205,6 +205,63 @@ func TestAcceptsGzip_Wildcard(t *testing.T) { } } +func TestNewGzipHandler_Flush(t *testing.T) { + flushed := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "chunk") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + + fr := &flushRecorder{ResponseRecorder: httptest.NewRecorder(), onFlush: func() { flushed = true }} + NewGzipHandler(inner).ServeHTTP(fr, req) + + if !flushed { + t.Fatal("Flush was not propagated to the underlying ResponseWriter") + } +} + +func TestAcceptsGzip_DeflateOnly(t *testing.T) { + body := `{"jsonrpc":"2.0","id":1}` + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "deflate") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no compression for Accept-Encoding: deflate, got %q", got) + } + if rr.Body.String() != body { + t.Fatalf("body mismatch: %q", rr.Body.String()) + } +} + +func TestAcceptsGzip_MultipleEncodings(t *testing.T) { + body := strings.Repeat("hello world ", 100) + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip, deflate") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected gzip for Accept-Encoding: gzip, deflate, got %q", got) + } +} + type hijackableRecorder struct { *httptest.ResponseRecorder } @@ -212,3 +269,15 @@ type hijackableRecorder struct { func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, nil } + +// flushRecorder wraps httptest.ResponseRecorder and implements http.Flusher, +// calling onFlush when Flush is invoked. +type flushRecorder struct { + *httptest.ResponseRecorder + onFlush func() +} + +func (f *flushRecorder) Flush() { + f.onFlush() + f.ResponseRecorder.Flush() +} From 9306c22b8ba368522cd333987dae32b6fb5571b1 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 15 Jun 2026 17:59:57 -0700 Subject: [PATCH 05/10] Added guard for content encoding --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 6 +++--- sei-tendermint/rpc/jsonrpc/server/gzip_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index 08045c5021..c93228ab2d 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -41,9 +41,9 @@ func (w *gzipResponseWriter) init() { } } - // Transfer-Encoding: identity signals that the inner handler wants to opt - // out of compression (e.g. error responses near the write deadline). - if hdr.Get("transfer-encoding") == "identity" { + // Skip compression if the inner handler already encoded the response or + // explicitly opted out via Transfer-Encoding: identity. + if hdr.Get("content-encoding") != "" || hdr.Get("transfer-encoding") == "identity" { return } diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go index b1756718e8..95b87c48ff 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go @@ -205,6 +205,24 @@ func TestAcceptsGzip_Wildcard(t *testing.T) { } } +func TestNewGzipHandler_PreExistingContentEncodingPassthrough(t *testing.T) { + body := "already-compressed-data" + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + _, _ = io.WriteString(w, body) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + NewGzipHandler(inner).ServeHTTP(rr, req) + + if rr.Body.String() != body { + t.Fatalf("body must pass through unmodified, got %q", rr.Body.String()) + } +} + func TestNewGzipHandler_Flush(t *testing.T) { flushed := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From af77abc48aac8bb72289c834710697ebc739a954 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 17 Jun 2026 11:11:29 -0700 Subject: [PATCH 06/10] Addressed level 6 compression + no size floor problem --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index c93228ab2d..f12a18fef7 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -9,9 +9,11 @@ import ( "sync" ) +const minCompressBytes = 1024 + var gzPool = sync.Pool{ New: func() any { - w := gzip.NewWriter(io.Discard) + w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed) return w }, } @@ -47,6 +49,13 @@ func (w *gzipResponseWriter) init() { return } + // Skip compression for small responses with a known Content-Length; below + // the threshold the gzip overhead outweighs the savings and the CPU cost + // per byte is not worth it for unauthenticated callers. + if w.hasLength && w.contentLength < minCompressBytes { + return + } + w.gz = gzPool.Get().(*gzip.Writer) w.gz.Reset(w.resp) hdr.Del("content-length") From 7148f8c0ec7ec0557b45c4993a2bc781eca6a45e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 17 Jun 2026 11:30:29 -0700 Subject: [PATCH 07/10] Ensured adding Vary header once --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index f12a18fef7..a42b716338 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -60,7 +60,6 @@ func (w *gzipResponseWriter) init() { w.gz.Reset(w.resp) hdr.Del("content-length") hdr.Set("content-encoding", "gzip") - hdr.Add("vary", "Accept-Encoding") } func (w *gzipResponseWriter) Header() http.Header { @@ -153,11 +152,33 @@ func acceptsGzip(r *http.Request) bool { return false } +// ensureVaryAcceptEncoding appends Accept-Encoding to the Vary header exactly +// once, deduplicating against any value already set by the inner handler or +// CORS middleware. Vary: * already implies Accept-Encoding, so it is left as-is. +func ensureVaryAcceptEncoding(h http.Header) { + existing := h.Get("Vary") + for part := range strings.SplitSeq(existing, ",") { + v := strings.TrimSpace(part) + if strings.EqualFold(v, "Accept-Encoding") || v == "*" { + return + } + } + if existing == "" { + h.Set("Vary", "Accept-Encoding") + } else { + h.Set("Vary", existing+", Accept-Encoding") + } +} + // NewGzipHandler wraps next with gzip response compression. Compression is // skipped for clients that do not advertise gzip support via Accept-Encoding // and for WebSocket upgrade requests, preserving the http.Hijacker interface. func NewGzipHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Vary must be set on every response — compressed or not — so that CDN + // caches key on Accept-Encoding and never serve a wrong variant. + ensureVaryAcceptEncoding(w.Header()) + if !acceptsGzip(r) || strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { next.ServeHTTP(w, r) return From a9086dea0f72ed80d056e319673fae18965f282a Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 17 Jun 2026 11:36:51 -0700 Subject: [PATCH 08/10] removed the early-close block and the written field --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index a42b716338..bde3f9bd19 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -23,7 +23,6 @@ type gzipResponseWriter struct { gz *gzip.Writer contentLength uint64 - written uint64 hasLength bool inited bool } @@ -86,16 +85,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { return w.resp.Write(b) } - n, err := w.gz.Write(b) - w.written += uint64(n) //nolint:gosec - if w.hasLength && w.written >= w.contentLength { - if cerr := w.gz.Close(); cerr != nil && err == nil { - err = cerr - } - gzPool.Put(w.gz) - w.gz = nil - } - return n, err + return w.gz.Write(b) } func (w *gzipResponseWriter) Flush() { From 8c8e7ac6f14887adf0137fc758dd6af6371b9336 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Wed, 17 Jun 2026 11:49:56 -0700 Subject: [PATCH 09/10] Added hijacker support --- sei-tendermint/rpc/jsonrpc/server/gzip.go | 34 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip.go b/sei-tendermint/rpc/jsonrpc/server/gzip.go index bde3f9bd19..7465320878 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip.go @@ -1,8 +1,11 @@ package server import ( + "bufio" "compress/gzip" + "fmt" "io" + "net" "net/http" "strconv" "strings" @@ -97,6 +100,17 @@ func (w *gzipResponseWriter) Flush() { } } +// Hijack implements http.Hijacker by forwarding to the inner ResponseWriter. +// The gzip writer is closed first so the hijacked connection starts clean. +func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := w.resp.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("gzip middleware: underlying ResponseWriter does not implement http.Hijacker") + } + w.close() + return h.Hijack() +} + func (w *gzipResponseWriter) close() { if w.gz == nil { return @@ -160,16 +174,30 @@ func ensureVaryAcceptEncoding(h http.Header) { } } +// hasUpgradeToken reports whether the Upgrade header contains token (RFC 7230 +// §6.7 permits a comma-separated list; each token is matched case-insensitively). +func hasUpgradeToken(r *http.Request, token string) bool { + for _, field := range r.Header["Upgrade"] { + for part := range strings.SplitSeq(field, ",") { + if strings.EqualFold(strings.TrimSpace(part), token) { + return true + } + } + } + return false +} + // NewGzipHandler wraps next with gzip response compression. Compression is -// skipped for clients that do not advertise gzip support via Accept-Encoding -// and for WebSocket upgrade requests, preserving the http.Hijacker interface. +// skipped for clients that do not advertise gzip support via Accept-Encoding. +// WebSocket upgrades are passed through unmodified; gzipResponseWriter also +// implements http.Hijacker as defense-in-depth for non-canonical Upgrade values. func NewGzipHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Vary must be set on every response — compressed or not — so that CDN // caches key on Accept-Encoding and never serve a wrong variant. ensureVaryAcceptEncoding(w.Header()) - if !acceptsGzip(r) || strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + if !acceptsGzip(r) || hasUpgradeToken(r, "websocket") { next.ServeHTTP(w, r) return } From fb7ce82826dbe70cc4156f8d36f3b7cd4bed28f6 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 18 Jun 2026 13:14:37 -0700 Subject: [PATCH 10/10] added more test coverage for gzip handler --- .../rpc/jsonrpc/server/gzip_test.go | 436 +++++++++++++++--- 1 file changed, 366 insertions(+), 70 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go index 95b87c48ff..4bf3739c5d 100644 --- a/sei-tendermint/rpc/jsonrpc/server/gzip_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/gzip_test.go @@ -2,51 +2,303 @@ package server import ( "bufio" + "bytes" "compress/gzip" + "errors" + "fmt" "io" "net" "net/http" "net/http/httptest" "strconv" "strings" + "sync" "testing" + "time" ) -func TestNewGzipHandler_CompressesWhenAccepted(t *testing.T) { - body := strings.Repeat("hello world ", 100) +// noAutoDecompressClient reads the raw on-the-wire body. Go's default client +// transparently gunzips responses and would hide the stream bugs we target. +var noAutoDecompressClient = &http.Client{ + Transport: &http.Transport{DisableCompression: true}, +} + +func expectsCompression(body []byte, setContentLength bool) bool { + if setContentLength && len(body) < minCompressBytes { + return false + } + return true +} + +// gzipDecodeStrict decodes a gzip response and returns an error if any bytes +// remain after the gzip footer — the check that catches early-close corruption. +// Safe to call from any goroutine; use readGzipBodyStrict from the test goroutine. +func gzipDecodeStrict(body io.Reader, want []byte) error { + gr, err := gzip.NewReader(body) + if err != nil { + return fmt.Errorf("gzip.NewReader: %w", err) + } + gr.Multistream(false) + + got, err := io.ReadAll(gr) + if err != nil { + return fmt.Errorf("gzip decode: %w", err) + } + if err := gr.Close(); err != nil { + return fmt.Errorf("gzip.Reader.Close: %w", err) + } + if !bytes.Equal(got, want) { + return fmt.Errorf("decoded %d bytes, want %d", len(got), len(want)) + } + + var extra [1]byte + n, err := body.Read(extra[:]) + if n != 0 { + return fmt.Errorf("trailing byte(s) after gzip stream: %q", extra[:n]) + } + if err != io.EOF { + return fmt.Errorf("expected io.EOF after gzip stream, got %v", err) + } + return nil +} + +// readGzipBodyStrict calls gzipDecodeStrict and fails the test on error. +// Must only be called from the test goroutine; use gzipDecodeStrict from workers. +func readGzipBodyStrict(t *testing.T, body io.Reader, want []byte) { + t.Helper() + if err := gzipDecodeStrict(body, want); err != nil { + t.Fatal(err) + } +} + +func assertGzipRoundTrip(t *testing.T, body []byte, setContentLength bool) { + t.Helper() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, body) + if setContentLength { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + } + if _, err := w.Write(body); err != nil { + t.Errorf("handler write: %v", err) + } }) - req := httptest.NewRequest(http.MethodGet, "/status", nil) - req.Header.Set("Accept-Encoding", "gzip") - rr := httptest.NewRecorder() + srv := httptest.NewServer(NewGzipHandler(inner)) + defer srv.Close() - NewGzipHandler(inner).ServeHTTP(rr, req) + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept-Encoding", "gzip") - if got := rr.Header().Get("Content-Encoding"); got != "gzip" { - t.Fatalf("expected Content-Encoding: gzip, got %q", got) + resp, err := noAutoDecompressClient.Do(req) + if err != nil { + t.Fatal(err) } - if got := rr.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + defer resp.Body.Close() + + if got := resp.Header.Get("Vary"); !strings.Contains(got, "Accept-Encoding") { t.Fatalf("expected Vary to contain Accept-Encoding, got %q", got) } - r, err := gzip.NewReader(rr.Body) - if err != nil { - t.Fatalf("response body is not valid gzip: %v", err) + + if expectsCompression(body, setContentLength) { + if ce := resp.Header.Get("Content-Encoding"); ce != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", ce) + } + if setContentLength { + if cl := resp.Header.Get("Content-Length"); cl == strconv.Itoa(len(body)) { + t.Fatalf("response retained original uncompressed Content-Length %q", cl) + } + } + compressed, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if len(body) >= 1<<20 && len(compressed) >= len(body) { + t.Fatalf("compressed body (%d B) not smaller than original (%d B)", len(compressed), len(body)) + } + readGzipBodyStrict(t, bytes.NewReader(compressed), body) + return } - decoded, err := io.ReadAll(r) + + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("expected passthrough (no Content-Encoding), got %q", ce) + } + got, err := io.ReadAll(resp.Body) if err != nil { - t.Fatalf("gzip decode error: %v", err) + t.Fatal(err) + } + if !bytes.Equal(got, body) { + t.Fatalf("body mismatch") + } +} + +func TestNewGzipHandler_RoundTripSizes(t *testing.T) { + sizes := []int{0, 1, 4095, 4096, 4097, 1 << 20} + + for _, size := range sizes { + if testing.Short() && size > 65536 { + continue + } + body := make([]byte, size) + for i := range body { + body[i] = byte(i % 251) + } + + for _, withCL := range []bool{false, true} { + name := fmt.Sprintf("size=%d/content-length=%v", size, withCL) + t.Run(name, func(t *testing.T) { + assertGzipRoundTrip(t, body, withCL) + }) + } } - if string(decoded) != body { - t.Fatalf("decoded body mismatch") +} + +func TestNewGzipHandler_ConcurrentRoundTrip(t *testing.T) { + srv := httptest.NewServer(NewGzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + body := concurrentTestPayload(id) + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + if _, err := w.Write(body); err != nil { + t.Errorf("handler write: %v", err) + } + }))) + defer srv.Close() + + const workers = 200 + var wg sync.WaitGroup + wg.Add(workers) + + for i := range workers { + go func(id int) { + defer wg.Done() + idStr := strconv.Itoa(id) + want := concurrentTestPayload(idStr) + + req, err := http.NewRequest(http.MethodGet, srv.URL+"?id="+idStr, nil) + if err != nil { + t.Errorf("worker %d: NewRequest: %v", id, err) + return + } + req.Header.Set("Accept-Encoding", "gzip") + + resp, err := noAutoDecompressClient.Do(req) + if err != nil { + t.Errorf("worker %d: Do: %v", id, err) + return + } + defer resp.Body.Close() + + if err := gzipDecodeStrict(resp.Body, want); err != nil { + t.Errorf("worker %d: %v", id, err) + } + }(i) } - if rr.Body.Len() >= len(body) { - t.Fatalf("compressed size (%d) not smaller than original (%d)", rr.Body.Len(), len(body)) + wg.Wait() +} + +func TestNewGzipHandler_StreamingFlush(t *testing.T) { + flushed := make(chan struct{}) + proceed := make(chan struct{}) + // handlerErr carries failures from the server handler goroutine where + // t.Fatal is unsafe; the outer select drains it alongside errCh. + handlerErr := make(chan error, 1) + + want := []byte("chunk-one-chunk-two") + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte("chunk-one-")); err != nil { + t.Errorf("write chunk1: %v", err) + } + f, ok := w.(http.Flusher) + if !ok { + handlerErr <- fmt.Errorf("handler ResponseWriter must implement http.Flusher") + return + } + f.Flush() + close(flushed) + <-proceed + if _, err := w.Write([]byte("chunk-two")); err != nil { + t.Errorf("write chunk2: %v", err) + } + }) + + srv := httptest.NewServer(NewGzipHandler(inner)) + defer srv.Close() + + errCh := make(chan error, 1) + go func() { + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + if err != nil { + errCh <- err + return + } + req.Header.Set("Accept-Encoding", "gzip") + + resp, err := noAutoDecompressClient.Do(req) + if err != nil { + errCh <- err + return + } + defer resp.Body.Close() + + select { + case <-flushed: + case <-time.After(5 * time.Second): + errCh <- fmt.Errorf("server never flushed first chunk") + return + } + + // firstByteCh fires when the reading goroutine receives the first gzip + // byte, confirming that flushed data arrived at the client before + // chunk-two is written. Using a notifier avoids relying on a single + // Read returning ≥1 byte, and lets io.ReadAll collect the full body so + // no append is needed. + firstByteCh := make(chan struct{}) + notifier := &firstByteReader{r: resp.Body, notifyCh: firstByteCh} + + bodyC := make(chan []byte, 1) + readErrC := make(chan error, 1) + go func() { + data, err := io.ReadAll(notifier) + bodyC <- data + readErrC <- err + }() + + select { + case <-firstByteCh: + case <-time.After(5 * time.Second): + errCh <- fmt.Errorf("client blocked waiting for bytes after server flush") + return + } + + close(proceed) + + data := <-bodyC + if err := <-readErrC; err != nil { + errCh <- err + return + } + errCh <- gzipDecodeStrict(bytes.NewReader(data), want) + }() + + select { + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + case err := <-handlerErr: + t.Fatal(err) + case <-time.After(10 * time.Second): + t.Fatal("streaming flush test deadlocked") } } +func concurrentTestPayload(id string) []byte { + // Unique per request and above minCompressBytes so Content-Length path compresses. + return []byte(strings.Repeat(id+"_", 512)) +} + func TestNewGzipHandler_PassthroughWithoutAcceptEncoding(t *testing.T) { body := `{"jsonrpc":"2.0","id":1}` inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -58,6 +310,9 @@ func TestNewGzipHandler_PassthroughWithoutAcceptEncoding(t *testing.T) { NewGzipHandler(inner).ServeHTTP(rr, req) + if got := rr.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + t.Fatalf("expected Vary to contain Accept-Encoding on passthrough responses, got %q", got) + } if got := rr.Header().Get("Content-Encoding"); got != "" { t.Fatalf("expected no Content-Encoding, got %q", got) } @@ -92,36 +347,58 @@ func TestNewGzipHandler_WebSocketPassthrough(t *testing.T) { } } -func TestNewGzipHandler_ContentLengthEarlyClose(t *testing.T) { - body := strings.Repeat("hello world ", 100) +func TestNewGzipHandler_WebSocketUpgradeTokenList(t *testing.T) { + hijackCalled := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", strconv.Itoa(len(body))) - _, _ = io.WriteString(w, body) + if _, ok := w.(http.Hijacker); ok { + hijackCalled = true + } }) - req := httptest.NewRequest(http.MethodGet, "/status", nil) - req.Header.Set("Accept-Encoding", "gzip") - rr := httptest.NewRecorder() - - NewGzipHandler(inner).ServeHTTP(rr, req) + srv := httptest.NewServer(NewGzipHandler(inner)) + defer srv.Close() - if got := rr.Header().Get("Content-Encoding"); got != "gzip" { - t.Fatalf("expected Content-Encoding: gzip, got %q", got) - } - // Content-Length must be removed since the compressed length differs. - if got := rr.Header().Get("Content-Length"); got != "" { - t.Fatalf("expected Content-Length to be stripped, got %q", got) - } - gr, err := gzip.NewReader(rr.Body) + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) if err != nil { - t.Fatalf("response body is not valid gzip: %v", err) + t.Fatal(err) } - decoded, err := io.ReadAll(gr) + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Upgrade", "websocket, foo") + req.Header.Set("Connection", "Upgrade") + + resp, err := noAutoDecompressClient.Do(req) if err != nil { - t.Fatalf("gzip decode error: %v", err) + t.Fatal(err) } - if string(decoded) != body { - t.Fatalf("decoded body mismatch") + defer resp.Body.Close() + + if resp.Header.Get("Content-Encoding") != "" { + t.Fatal("gzip handler must not compress WebSocket upgrade requests") + } + if !hijackCalled { + t.Fatal("http.Hijacker must be accessible for non-canonical WebSocket upgrade values") + } +} + +func TestGzipResponseWriter_HijackForwarding(t *testing.T) { + hijackAsserted := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := w.(http.Hijacker); ok { + hijackAsserted = true + } + }) + + // No Upgrade header → gzipResponseWriter is created (no early return). + // hijackableRecorder implements http.Hijacker so the wrapper can forward it. + // This exercises gzipResponseWriter.Hijack() forwarding, not the early-return path. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + + rr := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} + NewGzipHandler(inner).ServeHTTP(rr, req) + + if !hijackAsserted { + t.Fatal("http.Hijacker must be accessible through gzipResponseWriter when no Upgrade header is present") } } @@ -168,6 +445,28 @@ func TestNewGzipHandler_NoBodyFor204(t *testing.T) { } } +func TestNewGzipHandler_CloseErrorIsSilent(t *testing.T) { + // gz.Close() is called with _ = to silence the error because headers are + // already sent and there is no recovery path. This test injects a broken + // underlying writer (simulating a dropped connection) and verifies that the + // handler does not panic. + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, strings.Repeat("x", minCompressBytes)) + }) + + rw := &alwaysErrorWriter{header: make(http.Header)} + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + + NewGzipHandler(inner).ServeHTTP(rw, req) // must not panic + + // init() set Content-Encoding before any write was attempted, so the header + // must reflect that the gzip path was entered even though every write failed. + if ce := rw.header.Get("Content-Encoding"); ce != "gzip" { + t.Fatalf("expected Content-Encoding: gzip to be set before writes failed, got %q", ce) + } +} + func TestAcceptsGzip_QValueZero(t *testing.T) { body := `{"jsonrpc":"2.0","id":1}` inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -223,26 +522,6 @@ func TestNewGzipHandler_PreExistingContentEncodingPassthrough(t *testing.T) { } } -func TestNewGzipHandler_Flush(t *testing.T) { - flushed := false - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.WriteString(w, "chunk") - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - }) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Accept-Encoding", "gzip") - - fr := &flushRecorder{ResponseRecorder: httptest.NewRecorder(), onFlush: func() { flushed = true }} - NewGzipHandler(inner).ServeHTTP(fr, req) - - if !flushed { - t.Fatal("Flush was not propagated to the underlying ResponseWriter") - } -} - func TestAcceptsGzip_DeflateOnly(t *testing.T) { body := `{"jsonrpc":"2.0","id":1}` inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -288,14 +567,31 @@ func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, nil } -// flushRecorder wraps httptest.ResponseRecorder and implements http.Flusher, -// calling onFlush when Flush is invoked. -type flushRecorder struct { - *httptest.ResponseRecorder - onFlush func() +// firstByteReader wraps an io.Reader and closes notifyCh the first time a +// Read call returns at least one byte. Used in streaming tests to confirm that +// flushed data arrives at the client without consuming the byte separately. +type firstByteReader struct { + r io.Reader + once sync.Once + notifyCh chan struct{} +} + +func (f *firstByteReader) Read(p []byte) (int, error) { + n, err := f.r.Read(p) + if n > 0 { + f.once.Do(func() { close(f.notifyCh) }) + } + return n, err +} + +// alwaysErrorWriter is a ResponseWriter whose Write always fails, simulating a +// dropped connection. Used to verify gz.Close() errors are silenced safely. +type alwaysErrorWriter struct { + header http.Header } -func (f *flushRecorder) Flush() { - f.onFlush() - f.ResponseRecorder.Flush() +func (a *alwaysErrorWriter) Header() http.Header { return a.header } +func (a *alwaysErrorWriter) WriteHeader(_ int) {} +func (a *alwaysErrorWriter) Write(_ []byte) (int, error) { + return 0, errors.New("injected write failure") }