Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 03:30 UTC
reverseproxy: Keep streams attached when writer detachment fails (#8139)
Francis Lavoie committed
Oct 4, 2026 at 12:46 UTC
8ad83ebd4316d47fa40d11181eef148f018ec159
4 files changed
+159
-13
.github/workflows/ci.yml
+1
-1
@@ -248,4 +248,4 @@ jobs:
248
version: latest
249
args: build --single-target --snapshot
250
env:
251
- TAG: ${{ github.head_ref || github.ref_name }}
251
+ TAG: ${{ github.event.pull_request.head.sha || github.sha }}
AGENTS.md
+6
@@ -117,6 +117,12 @@ Caddy is built around a **module system** where everything is a module registere
117
118
Certificate management logic is also treated carefully, and is spread across caddyserver/caddy and caddyserver/certmagic repositories.
119
120
+## Branch Naming and Module Queries
121
+
122
+- Use lowercase, hyphen-separated branch names without slashes, such as `fix-stream-detach-fallback`. This lets contributors use branch names directly in `go get module@branch` and `xcaddy build branch` with the default Go module proxy settings.
123
+- Go's module-proxy version queries reject slash-containing branch names with `disallowed version string`. Shell quoting or URL-encoding the slash does not solve this. Use the branch's commit SHA instead; Go resolves it to a canonical version or pseudo-version. See [Go's version-query reference](https://go.dev/ref/mod#version-queries) and [version escaping rules](https://go.dev/src/cmd/vendor/golang.org/x/mod/module/module.go).
124
+- `GOPROXY=direct` can resolve slash-containing branches through Git, but do not rely on bypassing the module proxy for normal contributor builds. CI snapshot builds should use the PR head commit SHA so they build the exact revision regardless of branch naming.
125
+
126
## Quality Gates
127
128
modules/caddyhttp/reverseproxy/streaming.go
+13
-12
@@ -99,20 +99,10 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
99
bufferSize := h.StreamBufferSize
100
streamTimeout := time.Duration(h.StreamTimeout)
101
102
- if h.StreamDetached {
103
- // the return value should be true as it's not hijacked yet,
104
- // but some middleware may wrap response writers incorrectly
105
- if !caddyhttp.DetachResponseWriterAfterHijack(rw, true) {
106
- if c := logger.Check(zap.DebugLevel, "detaching connection failed"); c != nil {
107
- c.Write(zap.String("tip", "check if your response writers have an Unwrap method or if already hijacked"))
108
- }
109
- }
110
- }
111
-
102
var (
103
conn io.ReadWriteCloser
104
brw *bufio.ReadWriter
115
- detached = h.StreamDetached
105
+ detached bool
106
)
107
// websocket over http2 or http3 if extended connect is enabled,
108
// assuming backend doesn't support this, the request will be
@@ -122,7 +112,6 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
112
if body, ok := caddyhttp.GetVar(req.Context(), "extended_connect_websocket_body").(io.ReadCloser); ok {
113
// websocket over extended connect can't be detached. rw and req.Body
114
// are only valid while the handler goroutine is running
125
- detached = false
115
req.Body = body
116
rw.Header().Del("Upgrade")
117
rw.Header().Del("Connection")
@@ -145,6 +134,18 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
134
// bufio is not needed, use minimal buffer
135
brw = bufio.NewReadWriter(bufio.NewReaderSize(conn, 1), bufio.NewWriterSize(conn, 1))
136
} else {
137
+ if h.StreamDetached {
138
+ detached = caddyhttp.DetachResponseWriterAfterHijack(rw, true)
139
+ if !detached {
140
+ // Some outer writers may have accepted detachment before an
141
+ // inner writer refused it. Restore attached byte accounting
142
+ // and keep the handler running for the lifetime of the tunnel.
143
+ caddyhttp.DetachResponseWriterAfterHijack(rw, false)
144
+ if c := logger.Check(zap.DebugLevel, "detaching connection failed; keeping stream attached"); c != nil {
145
+ c.Write(zap.String("tip", "check if your response writers have an Unwrap method or if already hijacked"))
146
+ }
147
+ }
148
+ }
149
rw.WriteHeader(res.StatusCode)
150
151
if c := logger.Check(zap.DebugLevel, "upgrading connection"); c != nil {
modules/caddyhttp/reverseproxy/streaming_test.go
+139
@@ -642,3 +642,142 @@ func TestDetachedTrackerRemovalAllowsConcurrentConnectionDeletion(t *testing.T)
642
t.Error("removed upstream connection was not closed")
643
}
644
}
645
+
646
+// opaqueUpgradeWriter supports hijacking but hides the recorder from the
647
+// detachment helper, as a middleware wrapper without Unwrap would do.
648
+type opaqueUpgradeWriter struct {
649
+ http.ResponseWriter
650
+}
651
+
652
+func (w opaqueUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
653
+ return http.NewResponseController(w.ResponseWriter).Hijack()
654
+}
655
+
656
+type rejectingUpgradeWriter struct {
657
+ *caddyhttp.ResponseWriterWrapper
658
+}
659
+
660
+func (rejectingUpgradeWriter) DetachAfterHijack(detached bool) bool {
661
+ return !detached
662
+}
663
+
664
+type pipeUpgradeWriter struct {
665
+ *httptest.ResponseRecorder
666
+ conn net.Conn
667
+}
668
+
669
+func (w pipeUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
670
+ return w.conn, bufio.NewReadWriter(bufio.NewReader(w.conn), bufio.NewWriter(w.conn)), nil
671
+}
672
+
673
+func TestUpgradeDetachmentRequiresWriterSupport(t *testing.T) {
674
+ for _, tc := range []struct {
675
+ name string
676
+ wrap func(http.ResponseWriter) http.ResponseWriter
677
+ detached bool
678
+ }{
679
+ {name: "opaque wrapper", wrap: func(w http.ResponseWriter) http.ResponseWriter { return opaqueUpgradeWriter{w} }},
680
+ {name: "inner writer rejects detachment", wrap: func(w http.ResponseWriter) http.ResponseWriter {
681
+ rejecting := rejectingUpgradeWriter{&caddyhttp.ResponseWriterWrapper{ResponseWriter: w}}
682
+ return caddyhttp.NewResponseRecorder(rejecting, nil, nil)
683
+ }},
684
+ {name: "supported recorder", wrap: func(w http.ResponseWriter) http.ResponseWriter { return w }, detached: true},
685
+ } {
686
+ t.Run(tc.name, func(t *testing.T) {
687
+ front, client := net.Pipe()
688
+ back, backend := net.Pipe()
689
+ defer front.Close()
690
+ defer client.Close()
691
+ defer back.Close()
692
+ defer backend.Close()
693
+ if err := client.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
694
+ t.Fatal(err)
695
+ }
696
+ echoDone := make(chan struct{})
697
+ go func() {
698
+ defer close(echoDone)
699
+ _, _ = io.Copy(backend, backend)
700
+ }()
701
+ h := &Handler{StreamDetached: true, logger: caddy.Log(), tunnelTracker: newTunnelTracker(caddy.Log(), 0)}
702
+ initReverseProxyMetrics(h, prometheus.NewRegistry())
703
+ recorder := caddyhttp.NewResponseRecorder(pipeUpgradeWriter{httptest.NewRecorder(), front}, nil, nil)
704
+ writer := tc.wrap(recorder)
705
+ req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil))
706
+ req.Header.Set("Connection", "Upgrade")
707
+ req.Header.Set("Upgrade", "test-stream")
708
+ res := &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: req.Header.Clone(), Body: back}
709
+ handlerDone := make(chan struct{})
710
+ go func() {
711
+ defer close(handlerDone)
712
+ h.handleUpgradeResponse(caddy.Log(), writer, req, res, "detachment-test")
713
+ }()
714
+ const payload = "echo"
715
+ if _, err := io.WriteString(client, payload); err != nil {
716
+ t.Fatal(err)
717
+ }
718
+ received := make([]byte, len(payload))
719
+ if _, err := io.ReadFull(client, received); err != nil {
720
+ t.Fatal(err)
721
+ }
722
+ if string(received) != payload {
723
+ t.Fatalf("echo = %q, want %q", received, payload)
724
+ }
725
+ h.tunnelTracker.mu.Lock()
726
+ for _, conn := range h.tunnelTracker.connections {
727
+ if conn.detached != tc.detached {
728
+ t.Errorf("tracked detached = %v, want %v", conn.detached, tc.detached)
729
+ }
730
+ }
731
+ h.tunnelTracker.mu.Unlock()
732
+ if tc.detached {
733
+ select {
734
+ case <-handlerDone:
735
+ case <-time.After(time.Second):
736
+ t.Error("supported detached tunnel kept the handler running")
737
+ }
738
+ } else {
739
+ select {
740
+ case <-handlerDone:
741
+ t.Error("handler returned while the writer chain remained attached")
742
+ case <-time.After(100 * time.Millisecond):
743
+ }
744
+ }
745
+ client.Close()
746
+ backend.Close()
747
+ select {
748
+ case <-handlerDone:
749
+ case <-time.After(time.Second):
750
+ t.Fatal("handler did not finish after stream close")
751
+ }
752
+ <-echoDone
753
+ // Wait for detached tunnel cleanup too, so no copy goroutine can
754
+ // still be updating counters after this subtest finishes.
755
+ deadline := time.Now().Add(time.Second)
756
+ for {
757
+ h.tunnelTracker.mu.Lock()
758
+ remaining := len(h.tunnelTracker.connections)
759
+ h.tunnelTracker.mu.Unlock()
760
+ if remaining == 0 {
761
+ break
762
+ }
763
+ if time.Now().After(deadline) {
764
+ t.Fatal("tunnel connections were not unregistered")
765
+ }
766
+ time.Sleep(time.Millisecond)
767
+ }
768
+ // Attached byte counters are safe to inspect after the handler has waited
769
+ // for both copying goroutines. Partial detachment must also be undone.
770
+ if !tc.detached {
771
+ recorders := []caddyhttp.ResponseRecorder{recorder}
772
+ if outer, ok := writer.(caddyhttp.ResponseRecorder); ok {
773
+ recorders = append(recorders, outer)
774
+ }
775
+ for _, rr := range recorders {
776
+ if rr.Size() != len(payload) {
777
+ t.Errorf("attached recorder size = %d, want %d", rr.Size(), len(payload))
778
+ }
779
+ }
780
+ }
781
+ })
782
+ }
783
+}