Describe the bug
When the edge-to-cloudflared transport is QUIC and cloudflared connects to an origin using HTTP/2 (http2Origin: true), a streaming request can receive the complete origin response payload but never receive response EOF.
The trigger is an HTTP/2 origin that finishes its response while the request body remains open and idle. No reverse proxy, gRPC server, or application framework is required.
On the QUIC path, cloudflared presents nopCloserReadWriter as the origin request body. Its Close method only sets an atomic flag:
func (np *nopCloserReadWriter) Close() error {
atomic.StoreUint32(&np.closed, 1)
return nil
}
This prevents a future Read, but it does not interrupt a Read already blocked in the underlying QUIC stream. This violates the net/http request-body contract, which requires Close to unblock a concurrent Read.
With an HTTP/2 origin, response cleanup can wait for the request-body writer to exit. The real QUIC CancelRead(0) occurs only during the outer stream cleanup, which cannot run until the proxy handler returns:
HTTP/2 origin response cleanup
-> waits for the request-body writer
-> writer is blocked in the incoming QUIC stream Read
-> CancelRead occurs only in outer runStream cleanup
-> runStream cleanup waits for the proxy handler to return
To Reproduce
End-to-end reproducer using a raw HTTP/2 origin
cloudflared-quic-h2-minimal-repro.zip
The reproducer consists of two small Go programs and has no external Go dependencies:
- A raw TLS HTTP/2 origin.
- An HTTP/2 streaming client.
The raw origin:
- Negotiates
h2 with ALPN.
- Waits until it receives the first non-empty request DATA frame.
- Waits briefly so cloudflared's origin request writer blocks on its next request-body read.
- Sends
:status: 200 and the payload complete-body\n.
- Sends response
END_STREAM without resetting or closing the request direction.
- Continues accepting request DATA.
The client declares a 1 MiB request body, sends one byte, and deliberately leaves the remainder open. It verifies separately that the response payload and response EOF arrive.
-
Start the raw origin:
./repro-origin \
-mode h2 \
-addr 127.0.0.1:8443 \
-cert cert.pem \
-key key.pem
-
Verify the origin directly. This should pass:
./repro-client \
-url https://localhost:8443/ \
-insecure \
-eof-timeout 3s
Expected:
response payload: "complete-body\n"
PASS: response EOF arrived promptly
-
Configure a dedicated test Tunnel hostname to connect directly to the raw origin:
tunnel: <TUNNEL-ID>
credentials-file: /path/to/<TUNNEL-ID>.json
ingress:
- hostname: <TEST-HOSTNAME>
service: https://127.0.0.1:8443
originRequest:
http2Origin: true
noTLSVerify: true
- service: http_status:404
-
Start cloudflared with the edge transport forced to QUIC:
cloudflared tunnel \
--protocol quic \
--metrics 127.0.0.1:2000 \
--config ./config.yml \
run <TUNNEL>
-
Run the streaming client through the Tunnel:
./repro-client \
-url https://<TEST-HOSTNAME>/ \
-eof-timeout 3s \
-total-timeout 20s
-
The suspected failure has this signature:
response payload: "complete-body\n"
REPRODUCED: complete response body arrived, but no EOF after 3s
-
Keep http2Origin: true unchanged and restart only cloudflared with --protocol http2. Repeat the request. Response EOF should now arrive promptly.
-
Tunnel ID: <INSERT_OR_REDACT_TUNNEL_ID>
-
cloudflared configuration: shown above with hostname and credential path redacted.
Use a normal dedicated Tunnel hostname. Accountless Quick Tunnels may reject or buffer deliberately unfinished streaming requests before they reach cloudflared, which tests a different edge behavior.
Deterministic local regression test
The underlying defect can be reproduced without a Cloudflare account or network connection. Add a test that starts a read, waits until it is blocked in the underlying reader, calls nopCloserReadWriter.Close, and requires the read to return.
Against cloudflared 2026.7.1, the test fails consistently:
--- FAIL: TestNopCloserReadWriterCloseUnblocksPendingRead (0.25s)
quic_connection_test.go:621: nopCloserReadWriter.Close did not unblock the pending Read
FAIL
The same failure occurs with go test -race.
The existing nopCloserReadWriter tests only verify that a read started after Close fails. They do not verify that Close wakes a read already in progress.
Why the nghttp2 command-line tools are not an equivalent reproducer
I also tested whether the issue could be reproduced without the two small Go programs by using the standard nghttp2 command-line client and server. Those tools do not preserve the required stream lifetime:
-
nghttp --data=- reads stdin to EOF before opening the HTTP/2 request. Keeping stdin open therefore prevents the request from starting instead of creating an active request with an unfinished body.
-
curl --upload-file - can create the required active unfinished HTTP/2 upload, but nghttpd --early-response does not preserve the request direction after finishing its response.
-
After its response, nghttpd immediately resets the stream:
send HEADERS
send DATA ; END_STREAM
send RST_STREAM error_code=NO_ERROR
-
In a public QUIC-edge/HTTP2-origin test, cloudflared received that reset and logged:
error="stream error: stream ID 1; NO_ERROR; received from peer"
The reset explicitly cancels the still-open request direction, wakes the request-body writer, and masks the defect. An nghttp2-only four-row matrix therefore tests different teardown semantics and cannot confirm or refute this issue.
The raw origin in the reproduction is intentionally small because it must do something the packaged server does not expose as an option: send response END_STREAM, omit RST_STREAM, and continue accepting request DATA. The raw client must likewise start the request immediately, send one byte, keep its body open, and independently check whether response EOF arrives.
Protocol controls
Local integration tests produced this matrix while keeping the early response and unfinished request body unchanged:
| Edge to cloudflared |
cloudflared to origin |
Result |
| QUIC |
HTTP/2 |
Pending read is not interrupted |
| HTTP/2 |
HTTP/2 |
Completes promptly; 50/50 race-enabled runs passed |
| QUIC |
HTTP/1.1 |
Completes after approximately 50 ms |
| HTTP/2 |
HTTP/1.1 |
Completes after approximately 50 ms; 20/20 race-enabled runs passed |
For the HTTP/1.1-origin controls, Go's net/http.maxWriteWaitBeforeConnReuse 50 ms timer expires. The origin connection is marked non-reusable and response EOF is released. This avoids the hang but does not fix the QUIC body-close behavior.
Expected behavior
Closing the origin request body should interrupt any concurrent read from the incoming QUIC request stream while leaving the QUIC response-writing direction available.
When an HTTP/2 origin completes its response before the streaming request upload finishes, cloudflared should forward both the complete response payload and response EOF without waiting indefinitely for another request-body byte.
Environment and versions
- OS: macOS 26.5.2 (build 25F84)
- Architecture: ARM64
- cloudflared: 2026.7.1, built 2026-07-09
- cloudflared source commit tested:
ecb88678f1368581964ca2bb55615502a2ed2dbe
- Go: 1.26.5 darwin/arm64
Logs and errors
No cloudflared error is necessarily logged. The visible symptom is that the complete response payload arrives but response EOF does not.
During the deterministic test, the pending read remains blocked until the mock underlying stream is released. In a live goroutine dump, the expected blocked read is through:
connection.(*nopCloserReadWriter).Read
quic.(*SafeStreamCloser).Read
<underlying QUIC stream Read>
with HTTP/2 origin response cleanup waiting for the request-body writer to finish.
Additional context
runStream deliberately wraps SafeStreamCloser in nopCloserReadWriter so an inner HTTP request-body close cannot prematurely close the QUIC response-writing half. That separation is necessary, but the receive half still needs an interruptible close operation.
A possible fix is to add a read-side-only close method to SafeStreamCloser that calls the underlying QUIC stream's CancelRead(0), and have nopCloserReadWriter.Close invoke it exactly once. The existing outer full-stream cleanup should remain responsible for closing the write side.
A regression test should verify that:
- A read is already blocked in the underlying QUIC stream.
- Calling
nopCloserReadWriter.Close wakes that read promptly.
- The read returns an error.
- The QUIC write side remains usable so the origin response can still be forwarded.
Describe the bug
When the edge-to-cloudflared transport is QUIC and cloudflared connects to an origin using HTTP/2 (
http2Origin: true), a streaming request can receive the complete origin response payload but never receive response EOF.The trigger is an HTTP/2 origin that finishes its response while the request body remains open and idle. No reverse proxy, gRPC server, or application framework is required.
On the QUIC path, cloudflared presents
nopCloserReadWriteras the origin request body. ItsClosemethod only sets an atomic flag:This prevents a future
Read, but it does not interrupt aReadalready blocked in the underlying QUIC stream. This violates thenet/httprequest-body contract, which requiresCloseto unblock a concurrentRead.With an HTTP/2 origin, response cleanup can wait for the request-body writer to exit. The real QUIC
CancelRead(0)occurs only during the outer stream cleanup, which cannot run until the proxy handler returns:To Reproduce
End-to-end reproducer using a raw HTTP/2 origin
cloudflared-quic-h2-minimal-repro.zip
The reproducer consists of two small Go programs and has no external Go dependencies:
The raw origin:
h2with ALPN.:status: 200and the payloadcomplete-body\n.END_STREAMwithout resetting or closing the request direction.The client declares a 1 MiB request body, sends one byte, and deliberately leaves the remainder open. It verifies separately that the response payload and response EOF arrive.
Start the raw origin:
Verify the origin directly. This should pass:
Expected:
Configure a dedicated test Tunnel hostname to connect directly to the raw origin:
Start cloudflared with the edge transport forced to QUIC:
Run the streaming client through the Tunnel:
The suspected failure has this signature:
Keep
http2Origin: trueunchanged and restart only cloudflared with--protocol http2. Repeat the request. Response EOF should now arrive promptly.Tunnel ID:
<INSERT_OR_REDACT_TUNNEL_ID>cloudflared configuration: shown above with hostname and credential path redacted.
Use a normal dedicated Tunnel hostname. Accountless Quick Tunnels may reject or buffer deliberately unfinished streaming requests before they reach cloudflared, which tests a different edge behavior.
Deterministic local regression test
The underlying defect can be reproduced without a Cloudflare account or network connection. Add a test that starts a read, waits until it is blocked in the underlying reader, calls
nopCloserReadWriter.Close, and requires the read to return.Against cloudflared
2026.7.1, the test fails consistently:The same failure occurs with
go test -race.The existing
nopCloserReadWritertests only verify that a read started afterClosefails. They do not verify thatClosewakes a read already in progress.Why the nghttp2 command-line tools are not an equivalent reproducer
I also tested whether the issue could be reproduced without the two small Go programs by using the standard nghttp2 command-line client and server. Those tools do not preserve the required stream lifetime:
nghttp --data=-reads stdin to EOF before opening the HTTP/2 request. Keeping stdin open therefore prevents the request from starting instead of creating an active request with an unfinished body.curl --upload-file -can create the required active unfinished HTTP/2 upload, butnghttpd --early-responsedoes not preserve the request direction after finishing its response.After its response,
nghttpdimmediately resets the stream:In a public QUIC-edge/HTTP2-origin test, cloudflared received that reset and logged:
The reset explicitly cancels the still-open request direction, wakes the request-body writer, and masks the defect. An nghttp2-only four-row matrix therefore tests different teardown semantics and cannot confirm or refute this issue.
The raw origin in the reproduction is intentionally small because it must do something the packaged server does not expose as an option: send response
END_STREAM, omitRST_STREAM, and continue accepting request DATA. The raw client must likewise start the request immediately, send one byte, keep its body open, and independently check whether response EOF arrives.Protocol controls
Local integration tests produced this matrix while keeping the early response and unfinished request body unchanged:
For the HTTP/1.1-origin controls, Go's
net/http.maxWriteWaitBeforeConnReuse50 ms timer expires. The origin connection is marked non-reusable and response EOF is released. This avoids the hang but does not fix the QUIC body-close behavior.Expected behavior
Closing the origin request body should interrupt any concurrent read from the incoming QUIC request stream while leaving the QUIC response-writing direction available.
When an HTTP/2 origin completes its response before the streaming request upload finishes, cloudflared should forward both the complete response payload and response EOF without waiting indefinitely for another request-body byte.
Environment and versions
ecb88678f1368581964ca2bb55615502a2ed2dbeLogs and errors
No cloudflared error is necessarily logged. The visible symptom is that the complete response payload arrives but response EOF does not.
During the deterministic test, the pending read remains blocked until the mock underlying stream is released. In a live goroutine dump, the expected blocked read is through:
with HTTP/2 origin response cleanup waiting for the request-body writer to finish.
Additional context
runStreamdeliberately wrapsSafeStreamCloserinnopCloserReadWriterso an inner HTTP request-body close cannot prematurely close the QUIC response-writing half. That separation is necessary, but the receive half still needs an interruptible close operation.A possible fix is to add a read-side-only close method to
SafeStreamCloserthat calls the underlying QUIC stream'sCancelRead(0), and havenopCloserReadWriter.Closeinvoke it exactly once. The existing outer full-stream cleanup should remain responsible for closing the write side.A regression test should verify that:
nopCloserReadWriter.Closewakes that read promptly.