Files
caddy/modules/caddyhttp/reverseproxy/streaming_test.go

784 lines
23 KiB
Go

package reverseproxy
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func TestHandlerCopyResponse(t *testing.T) {
h := Handler{}
testdata := []string{
"",
strings.Repeat("a", defaultBufferSize),
strings.Repeat("123456789 123456789 123456789 12", 3000),
}
dst := bytes.NewBuffer(nil)
recorder := httptest.NewRecorder()
recorder.Body = dst
for _, d := range testdata {
src := bytes.NewBuffer([]byte(d))
dst.Reset()
err := h.copyResponse(recorder, src, 0, caddy.Log())
if err != nil {
t.Errorf("failed with error: %v", err)
}
out := dst.String()
if out != d {
t.Errorf("bad read: got %q", out)
}
}
}
func TestSwitchProtocolCopierBufferSize(t *testing.T) {
var wg sync.WaitGroup
var errc = make(chan error, 1)
var dst bytes.Buffer
var sent, received int64
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: nopReadWriteCloser{Writer: &dst},
wg: &wg,
bufferSize: 7,
sent: &sent,
received: &received,
}
buf := copier.buffer()
if got := len(buf); got != 7 {
t.Fatalf("buffer len = %d, want 7", got)
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
// The destination cannot be half-closed, so a clean copy reports errCopyDone.
if err := <-errc; !errors.Is(err, errCopyDone) {
t.Fatalf("copyToBackend() error = %v", err)
}
if got := dst.String(); got != "hello" {
t.Fatalf("copied data = %q, want %q", got, "hello")
}
}
// A clean EOF in one direction must be propagated to the destination as a
// write-half close instead of tearing the whole tunnel down.
// See https://github.com/caddyserver/caddy/issues/8026.
func TestSwitchProtocolCopierHalfClose(t *testing.T) {
closeWriteErr := errors.New("no half-close")
for _, tc := range []struct {
name string
dst io.ReadWriteCloser
wantErr error // nil means the half-close was propagated
}{
{
name: "destination supports CloseWrite",
dst: &closeWriteRecorder{},
wantErr: nil,
},
{
name: "destination does not support CloseWrite",
dst: nopReadWriteCloser{Writer: io.Discard},
wantErr: errCopyDone,
},
{
name: "CloseWrite fails",
dst: &closeWriteRecorder{err: closeWriteErr},
wantErr: closeWriteErr,
},
} {
t.Run(tc.name, func(t *testing.T) {
var wg sync.WaitGroup
var sent, received int64
errc := make(chan error, 1)
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: tc.dst,
wg: &wg,
sent: &sent,
received: &received,
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
if err := <-errc; !errors.Is(err, tc.wantErr) {
t.Fatalf("copyToBackend() error = %v, want %v", err, tc.wantErr)
}
if rec, ok := tc.dst.(*closeWriteRecorder); ok && !rec.called {
t.Fatal("CloseWrite() was not called on a destination that supports it")
}
})
}
}
// A copy error must be reported as-is and must not be mistaken for a clean
// half-close, so the tunnel is still torn down immediately.
func TestSwitchProtocolCopierErrorIsNotHalfClose(t *testing.T) {
var wg sync.WaitGroup
var sent, received int64
errc := make(chan error, 1)
wantErr := errors.New("write failed")
dst := &closeWriteRecorder{writeErr: wantErr}
copier := switchProtocolCopier{
user: nopReadWriteCloser{Reader: strings.NewReader("hello")},
backend: dst,
wg: &wg,
sent: &sent,
received: &received,
}
wg.Add(1)
go copier.copyToBackend(errc)
wg.Wait()
if err := <-errc; !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if dst.called {
t.Fatal("CloseWrite() was called after a copy error")
}
}
func TestSwitchProtocolCopierDefaultBufferSize(t *testing.T) {
copier := switchProtocolCopier{}
buf := copier.buffer()
if got := len(buf); got != defaultBufferSize {
t.Fatalf("buffer len = %d, want %d", got, defaultBufferSize)
}
}
type nopReadWriteCloser struct {
io.Reader
io.Writer
}
func (nopReadWriteCloser) Close() error { return nil }
type trackingReadWriteCloser struct {
closed chan struct{}
one sync.Once
}
func newTrackingReadWriteCloser() *trackingReadWriteCloser {
return &trackingReadWriteCloser{closed: make(chan struct{})}
}
func (c *trackingReadWriteCloser) Read(_ []byte) (int, error) { return 0, io.EOF }
func (c *trackingReadWriteCloser) Write(p []byte) (int, error) { return len(p), nil }
func (c *trackingReadWriteCloser) Close() error {
c.one.Do(func() {
close(c.closed)
})
return nil
}
func (c *trackingReadWriteCloser) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func TestHandlerCleanupLegacyModeClosesAllConnections(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 0)
connA := newTrackingReadWriteCloser()
connB := newTrackingReadWriteCloser()
ts.registerConnection(connA, nil, false, "a")
ts.registerConnection(connB, nil, false, "b")
h := &Handler{
tunnelTracker: ts,
StreamDetached: false,
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if !connA.isClosed() || !connB.isClosed() {
t.Fatalf("legacy cleanup should close all upgraded connections")
}
}
func TestHandlerCleanupLegacyModeHonorsDelay(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 40*time.Millisecond)
conn := newTrackingReadWriteCloser()
ts.registerConnection(conn, nil, false, "a")
h := &Handler{
tunnelTracker: ts,
StreamDetached: false,
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if conn.isClosed() {
t.Fatal("connection should not close immediately when stream_close_delay is set")
}
select {
case <-conn.closed:
case <-time.After(500 * time.Millisecond):
t.Fatal("connection did not close after stream_close_delay elapsed")
}
}
func TestHandlerCleanupDetachedModeClosesOnlyRemovedUpstreams(t *testing.T) {
const upstreamA = "upstream-a"
const upstreamB = "upstream-b"
// Simulate old+new configs both referencing upstreamA (refcount 2),
// while upstreamB is only referenced by the old config (refcount 1).
hosts.LoadOrStore(upstreamA, struct{}{})
hosts.LoadOrStore(upstreamA, struct{}{})
hosts.LoadOrStore(upstreamB, struct{}{})
t.Cleanup(func() {
_, _ = hosts.Delete(upstreamA)
_, _ = hosts.Delete(upstreamA)
_, _ = hosts.Delete(upstreamB)
})
ts := newTunnelTracker(caddy.Log(), 0)
registerDetachedTunnelTrackers(ts)
connA := newTrackingReadWriteCloser()
connB := newTrackingReadWriteCloser()
ts.registerConnection(connA, nil, true, upstreamA)
ts.registerConnection(connB, nil, true, upstreamB)
h := &Handler{
tunnelTracker: ts,
StreamDetached: true,
Upstreams: UpstreamPool{
&Upstream{Dial: upstreamA},
&Upstream{Dial: upstreamB},
},
}
if err := h.Cleanup(); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if connA.isClosed() {
t.Fatal("connection for detached upstream should remain open")
}
if !connB.isClosed() {
t.Fatal("connection for removed upstream should be closed")
}
}
func TestHandlerUnmarshalCaddyfileStreamLogsBlock(t *testing.T) {
d := caddyfile.NewTestDispenser(`
reverse_proxy localhost:9000 {
stream_logs {
level info
logger_name access
skip_handshake
}
}
`)
var h Handler
if err := h.UnmarshalCaddyfile(d); err != nil {
t.Fatalf("UnmarshalCaddyfile() error = %v", err)
}
if h.StreamLogs == nil {
t.Fatal("expected stream_logs to be configured")
}
if h.StreamLogs.Level != "info" {
t.Fatalf("expected stream_logs.level=info, got %q", h.StreamLogs.Level)
}
if h.StreamLogs.LoggerName != "access" {
t.Fatalf("expected stream_logs.logger_name=access, got %q", h.StreamLogs.LoggerName)
}
if !h.StreamLogs.SkipHandshake {
t.Fatal("expected stream_logs.skip_handshake=true")
}
}
// closeWriteRecorder is a destination that supports closing only its write
// half, and records whether that happened.
type closeWriteRecorder struct {
called bool
err error
writeErr error
}
func (c *closeWriteRecorder) Read([]byte) (int, error) { return 0, io.EOF }
func (c *closeWriteRecorder) Write(p []byte) (int, error) {
if c.writeErr != nil {
return 0, c.writeErr
}
return len(p), nil
}
func (c *closeWriteRecorder) Close() error { return nil }
func (c *closeWriteRecorder) CloseWrite() error {
c.called = true
return c.err
}
// TestHandlerUpgradedStreamHalfClose drives a real upgraded stream through the
// handler and checks that closing one direction is propagated to the other side
// instead of tearing the whole tunnel down.
//
// It mirrors net/http/httputil's TestReverseProxyWebSocketHalfTCP, which covers
// the same class upstream (https://go.dev/issue/35892). Before the fix, the
// "close write" cases failed: the handler returned as soon as the first
// direction reported, and the deferred closes killed the other direction before
// its pending bytes could be delivered.
//
// See https://github.com/caddyserver/caddy/issues/8026.
func TestHandlerUpgradedStreamHalfClose(t *testing.T) {
switch runtime.GOOS {
case "plan9", "js", "wasip1":
t.Skipf("not supported on %s", runtime.GOOS)
}
// Reads carry a deadline so a regression fails the test promptly instead of
// blocking until the whole package times out.
const readTimeout = 10 * time.Second
mustRead := func(t *testing.T, conn *net.TCPConn, msg string) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
b := make([]byte, len(msg))
if _, err := io.ReadFull(conn, b); err != nil {
t.Fatalf("failed to read: %v", err)
}
if got, want := string(b), msg; got != want {
t.Fatalf("got %#q, want %#q", got, want)
}
}
mustReadEOF := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
b := make([]byte, 1)
if _, err := conn.Read(b); !errors.Is(err, io.EOF) {
t.Fatalf("read after peer half-close: got %v, want EOF", err)
}
}
mustWrite := func(t *testing.T, conn *net.TCPConn, msg string) {
t.Helper()
if _, err := conn.Write([]byte(msg)); err != nil {
t.Fatalf("failed to write: %v", err)
}
}
mustCloseRead := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.CloseRead(); err != nil {
t.Fatalf("failed to CloseRead: %v", err)
}
}
mustCloseWrite := func(t *testing.T, conn *net.TCPConn) {
t.Helper()
if err := conn.CloseWrite(); err != nil {
t.Fatalf("failed to CloseWrite: %v", err)
}
}
tests := map[string]func(t *testing.T, cli, srv *net.TCPConn){
"backend close read": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseRead(t, srv)
mustWrite(t, srv, "backend sends")
mustRead(t, cli, "backend sends")
},
"backend close write": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseWrite(t, srv)
mustReadEOF(t, cli)
mustWrite(t, cli, "client sends")
mustRead(t, srv, "client sends")
},
"client close read": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseRead(t, cli)
mustWrite(t, cli, "client sends")
mustRead(t, srv, "client sends")
},
"client close write": func(t *testing.T, cli, srv *net.TCPConn) {
mustCloseWrite(t, cli)
mustReadEOF(t, srv)
mustWrite(t, srv, "backend sends")
mustRead(t, cli, "backend sends")
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
srvc := make(chan *net.TCPConn, 1)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, _, err := http.NewResponseController(w).Hijack()
if err != nil {
t.Errorf("backend hijack failed: %v", err)
return
}
tcp, ok := conn.(*net.TCPConn)
if !ok {
conn.Close()
t.Errorf("backend conn is %T, want *net.TCPConn", conn)
return
}
if _, err := io.WriteString(tcp, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); err != nil {
tcp.Close()
t.Errorf("backend upgrade write failed: %v", err)
return
}
srvc <- tcp
}))
defer backend.Close()
h := minimalHandler(0, &Upstream{
Host: new(Host),
Dial: backend.Listener.Addr().String(),
})
h.tunnelTracker = newTunnelTracker(caddy.Log(), 0)
initReverseProxyMetrics(h, prometheus.NewRegistry())
frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Isolate half-close propagation from request cancellation which has
// its own backend-close path.
r = r.WithContext(context.WithoutCancel(r.Context()))
_ = h.ServeHTTP(w, prepareTestRequest(r), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
return nil
}))
}))
defer frontend.Close()
frontendURL, err := url.Parse(frontend.URL)
if err != nil {
t.Fatalf("failed to parse frontend URL: %v", err)
}
addr, err := net.ResolveTCPAddr("tcp", frontendURL.Host)
if err != nil {
t.Fatalf("failed to resolve TCP address: %v", err)
}
cli, err := net.DialTCP("tcp", nil, addr)
if err != nil {
t.Fatalf("failed to dial frontend: %v", err)
}
defer cli.Close()
req, _ := http.NewRequest(http.MethodGet, frontend.URL, nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
if err := req.Write(cli); err != nil {
t.Fatalf("failed to write upgrade request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(cli), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatalf("failed to read upgrade response: %v", err)
}
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want 101", resp.StatusCode)
}
var srv *net.TCPConn
select {
case srv = <-srvc:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the backend connection")
}
defer srv.Close()
test(t, cli, srv)
})
}
}
// A response carrying the Incremental header field (RFC 10036) must be
// forwarded without buffering, whatever its Content-Type and Content-Length.
func TestFlushIntervalIncremental(t *testing.T) {
h := Handler{FlushInterval: caddy.Duration(time.Second)}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, tc := range []struct {
name string
incremental string
receivedIncremental bool
want time.Duration
}{
{name: "incremental", incremental: "?1", want: -1},
{name: "not incremental", incremental: "?0", want: time.Second},
{name: "absent", want: time.Second},
{name: "removed by header operations", receivedIncremental: true, want: -1},
} {
t.Run(tc.name, func(t *testing.T) {
res := &http.Response{
Header: http.Header{"Content-Type": []string{"application/json"}},
ContentLength: 42,
}
if tc.incremental != "" {
res.Header.Set("Incremental", tc.incremental)
}
if got := h.flushInterval(req, res, tc.receivedIncremental); got != tc.want {
t.Errorf("flushInterval() = %v, want %v", got, tc.want)
}
})
}
}
func TestHandlerCleanupDetachedRemovalWithDelay(t *testing.T) {
const upstream = "detached-removal-with-delay"
hosts.LoadOrStore(upstream, new(Host))
t.Cleanup(func() { _, _ = hosts.Delete(upstream) })
ts := newTunnelTracker(caddy.Log(), time.Hour)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
detached := newTrackingReadWriteCloser()
attached := newTrackingReadWriteCloser()
deleteDetached := ts.registerConnection(detached, nil, true, upstream)
deleteAttached := ts.registerConnection(attached, nil, false, upstream)
t.Cleanup(deleteDetached)
t.Cleanup(deleteAttached)
h := &Handler{tunnelTracker: ts, StreamDetached: true, Upstreams: UpstreamPool{&Upstream{Dial: upstream}}}
if err := h.Cleanup(); err != nil {
t.Fatal(err)
}
if !detached.isClosed() {
t.Error("detached stream remains open after upstream removal")
}
if attached.isClosed() {
t.Error("attached stream closed before stream_close_delay elapsed")
}
}
func TestHandlerCleanupEmptyDetachedTracker(t *testing.T) {
for _, delay := range []time.Duration{0, time.Hour} {
t.Run(delay.String(), func(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), delay)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
// Cover a tracker whose last connection closed before handler cleanup.
conn := newTrackingReadWriteCloser()
ts.registerConnection(conn, nil, true, "upstream")()
if err := (&Handler{tunnelTracker: ts, StreamDetached: true}).Cleanup(); err != nil {
t.Fatal(err)
}
detachedTunnelTrackersMu.Lock()
_, retained := detachedTunnelTrackers[ts]
detachedTunnelTrackersMu.Unlock()
if retained {
t.Error("empty tracker remains globally registered after cleanup")
}
})
}
}
func TestDetachedTrackerRemovalAllowsConcurrentConnectionDeletion(t *testing.T) {
ts := newTunnelTracker(caddy.Log(), 0)
registerDetachedTunnelTrackers(ts)
t.Cleanup(func() { unregisterDetachedTunnelTrackers(ts) })
// An attached stream on another stopped handler can finish while an
// upstream-removal notification closes a detached stream. Its deletion
// must be able to acquire the registry lock and release its tracker lock.
other := newTunnelTracker(caddy.Log(), 0)
otherConn := newTrackingReadWriteCloser()
deleteOther := other.registerConnection(otherConn, nil, false, "other")
if err := other.cleanupAttachedConnections(); err != nil {
t.Fatal(err)
}
deleted := make(chan struct{})
conn := newTrackingReadWriteCloser()
del := ts.registerConnection(conn, func() error {
go func() {
deleteOther()
close(deleted)
}()
select {
case <-deleted:
case <-time.After(time.Second):
t.Error("upstream removal blocked another connection's deletion")
}
return nil
}, true, "upstream")
t.Cleanup(del)
if err := ts.cleanupAttachedConnections(); err != nil {
t.Fatal(err)
}
if err := notifyDetachedTunnelTrackersOfUpstreamRemoval("upstream", ts); err != nil {
t.Fatal(err)
}
// On a regression the notification must release its lock before waiting
// for deletion, so the failing test does not leave a blocked goroutine.
<-deleted
if !conn.isClosed() {
t.Error("removed upstream connection was not closed")
}
}
// opaqueUpgradeWriter supports hijacking but hides the recorder from the
// detachment helper, as a middleware wrapper without Unwrap would do.
type opaqueUpgradeWriter struct {
http.ResponseWriter
}
func (w opaqueUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return http.NewResponseController(w.ResponseWriter).Hijack()
}
type rejectingUpgradeWriter struct {
*caddyhttp.ResponseWriterWrapper
}
func (rejectingUpgradeWriter) DetachAfterHijack(detached bool) bool {
return !detached
}
type pipeUpgradeWriter struct {
*httptest.ResponseRecorder
conn net.Conn
}
func (w pipeUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return w.conn, bufio.NewReadWriter(bufio.NewReader(w.conn), bufio.NewWriter(w.conn)), nil
}
func TestUpgradeDetachmentRequiresWriterSupport(t *testing.T) {
for _, tc := range []struct {
name string
wrap func(http.ResponseWriter) http.ResponseWriter
detached bool
}{
{name: "opaque wrapper", wrap: func(w http.ResponseWriter) http.ResponseWriter { return opaqueUpgradeWriter{w} }},
{name: "inner writer rejects detachment", wrap: func(w http.ResponseWriter) http.ResponseWriter {
rejecting := rejectingUpgradeWriter{&caddyhttp.ResponseWriterWrapper{ResponseWriter: w}}
return caddyhttp.NewResponseRecorder(rejecting, nil, nil)
}},
{name: "supported recorder", wrap: func(w http.ResponseWriter) http.ResponseWriter { return w }, detached: true},
} {
t.Run(tc.name, func(t *testing.T) {
front, client := net.Pipe()
back, backend := net.Pipe()
defer front.Close()
defer client.Close()
defer back.Close()
defer backend.Close()
if err := client.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
echoDone := make(chan struct{})
go func() {
defer close(echoDone)
_, _ = io.Copy(backend, backend)
}()
h := &Handler{StreamDetached: true, logger: caddy.Log(), tunnelTracker: newTunnelTracker(caddy.Log(), 0)}
initReverseProxyMetrics(h, prometheus.NewRegistry())
recorder := caddyhttp.NewResponseRecorder(pipeUpgradeWriter{httptest.NewRecorder(), front}, nil, nil)
writer := tc.wrap(recorder)
req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil))
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "test-stream")
res := &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: req.Header.Clone(), Body: back}
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
h.handleUpgradeResponse(caddy.Log(), writer, req, res, "detachment-test")
}()
const payload = "echo"
if _, err := io.WriteString(client, payload); err != nil {
t.Fatal(err)
}
received := make([]byte, len(payload))
if _, err := io.ReadFull(client, received); err != nil {
t.Fatal(err)
}
if string(received) != payload {
t.Fatalf("echo = %q, want %q", received, payload)
}
h.tunnelTracker.mu.Lock()
for _, conn := range h.tunnelTracker.connections {
if conn.detached != tc.detached {
t.Errorf("tracked detached = %v, want %v", conn.detached, tc.detached)
}
}
h.tunnelTracker.mu.Unlock()
if tc.detached {
select {
case <-handlerDone:
case <-time.After(time.Second):
t.Error("supported detached tunnel kept the handler running")
}
} else {
select {
case <-handlerDone:
t.Error("handler returned while the writer chain remained attached")
case <-time.After(100 * time.Millisecond):
}
}
client.Close()
backend.Close()
select {
case <-handlerDone:
case <-time.After(time.Second):
t.Fatal("handler did not finish after stream close")
}
<-echoDone
// Wait for detached tunnel cleanup too, so no copy goroutine can
// still be updating counters after this subtest finishes.
deadline := time.Now().Add(time.Second)
for {
h.tunnelTracker.mu.Lock()
remaining := len(h.tunnelTracker.connections)
h.tunnelTracker.mu.Unlock()
if remaining == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("tunnel connections were not unregistered")
}
time.Sleep(time.Millisecond)
}
// Attached byte counters are safe to inspect after the handler has waited
// for both copying goroutines. Partial detachment must also be undone.
if !tc.detached {
recorders := []caddyhttp.ResponseRecorder{recorder}
if outer, ok := writer.(caddyhttp.ResponseRecorder); ok {
recorders = append(recorders, outer)
}
for _, rr := range recorders {
if rr.Size() != len(payload) {
t.Errorf("attached recorder size = %d, want %d", rr.Size(), len(payload))
}
}
}
})
}
}