mirror of
https://github.com/caddyserver/caddy.git
synced 2026-09-15 15:17:25 -04:00
* encode: flush headers immediately for server-sent events responses The encode middleware withholds the response header until the first body write so it can sniff content-type and apply the minimum_length threshold. For a text/event-stream response the upstream typically writes headers and flushes to establish the event stream before any event body is available, so the client never received the headers and the stream stalled; the same buffering also delayed individual events. When WriteHeader sees a text/event-stream content type, initialize encoding and write the header through immediately. Forcing the header out also marks the response as started, so subsequent event writes bypass the minimum_length buffering and stream to the client as they arrive. Fixes #6293 * encode: add WriteHeader benchmark covering SSE fast path * encode: replace mime.ParseMediaType with bound-checked SSE check WriteHeader runs an SSE Content-Type check on every call once headers haven't been written yet. mime.ParseMediaType parses the full media type, including parameters, even when nothing matches, which shows up on the hot header-write path. Replace it with a bound-checked manual prefix/boundary check (isSSE), skipping parameter parsing for the common non-SSE case. * encode: reject content types with junk after text/event-stream isSSE accepted any suffix after a space, so a value like "text/event-stream nonsense" was treated as an SSE response. After the media type, skip optional whitespace and require either the end of the value or a parameter separator. The check remains allocation-free, so the hot-path motivation for the manual matcher is preserved. --------- Co-authored-by: SillyZir <269283839+SillyZir@users.noreply.github.com> Co-authored-by: Kévin Dunglas <kevin@les-tilleuls.coop>
135 lines
4.5 KiB
Go
135 lines
4.5 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package encode_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
|
|
caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
|
|
)
|
|
|
|
// recordingWriter records the moment WriteHeader reaches the underlying
|
|
// writer, so a test can distinguish "headers flushed to the client" from
|
|
// "headers still buffered inside the encoder".
|
|
type recordingWriter struct {
|
|
http.ResponseWriter
|
|
wroteHeader bool
|
|
status int
|
|
}
|
|
|
|
func (rw *recordingWriter) WriteHeader(status int) {
|
|
if !rw.wroteHeader {
|
|
rw.wroteHeader = true
|
|
rw.status = status
|
|
}
|
|
rw.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (rw *recordingWriter) Flush() {
|
|
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func newSSEEncodeHandler(t *testing.T) *encode.Encode {
|
|
t.Helper()
|
|
enc := &encode.Encode{
|
|
EncodingsRaw: caddy.ModuleMap{
|
|
"gzip": caddyconfig.JSON(caddygzip.Gzip{}, nil),
|
|
},
|
|
Prefer: []string{"gzip"},
|
|
// A large minimum_length means a normal small response would be
|
|
// buffered (its header withheld) until enough bytes arrive; the SSE
|
|
// path must bypass this so the handshake reaches the client.
|
|
MinLength: 4096,
|
|
}
|
|
ctx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
|
|
t.Cleanup(cancel)
|
|
if err := enc.Provision(ctx); err != nil {
|
|
t.Fatalf("Provision() error = %v", err)
|
|
}
|
|
if err := enc.Validate(); err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
return enc
|
|
}
|
|
|
|
// An SSE upstream typically writes headers and flushes to establish the
|
|
// event stream before any event body is available. The encode middleware
|
|
// must let those headers reach the client immediately rather than holding
|
|
// them for minimum_length content sniffing. See #6293.
|
|
func TestSSEHeadersFlushedBeforeBody(t *testing.T) {
|
|
enc := newSSEEncodeHandler(t)
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Accept-Encoding", "gzip")
|
|
baseRec := httptest.NewRecorder()
|
|
rec := &recordingWriter{ResponseWriter: baseRec}
|
|
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Flush the way a real SSE handler does — through the response
|
|
// controller (encode's writer implements FlushError, not Flush).
|
|
if err := http.NewResponseController(w).Flush(); err != nil {
|
|
t.Errorf("flush failed: %v", err)
|
|
}
|
|
// Before any event body is written, the client must already have the
|
|
// headers AND the flush must have reached the underlying writer.
|
|
if !rec.wroteHeader {
|
|
t.Error("SSE response headers were not written to the client before the body")
|
|
}
|
|
if rec.status != http.StatusOK {
|
|
t.Errorf("underlying status = %d, want 200", rec.status)
|
|
}
|
|
if !baseRec.Flushed {
|
|
t.Error("underlying ResponseRecorder was not flushed for the SSE handshake")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err := enc.ServeHTTP(rec, r, next); err != nil {
|
|
t.Fatalf("ServeHTTP() error = %v", err)
|
|
}
|
|
}
|
|
|
|
// A normal (non-SSE) small response is still allowed to buffer its header
|
|
// for content sniffing — the SSE change must not force every response to
|
|
// flush its header early. This guards the scope of the fix.
|
|
func TestNonSSESmallResponseStillBuffersHeader(t *testing.T) {
|
|
enc := newSSEEncodeHandler(t)
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Accept-Encoding", "gzip")
|
|
rec := &recordingWriter{ResponseWriter: httptest.NewRecorder()}
|
|
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
if rec.wroteHeader {
|
|
t.Error("non-SSE response flushed its header early; SSE bypass leaked to normal responses")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err := enc.ServeHTTP(rec, r, next); err != nil {
|
|
t.Fatalf("ServeHTTP() error = %v", err)
|
|
}
|
|
}
|