Found while reviewing #746; not related to shutdown. Pre-existing on main: measured on 698bed6.
Defect
On epoll and io_uring, an HTTP/1.1 response whose body is 4 MiB or more is sent as its headers only. The body is dropped silently, the connection stays open, and a keep-alive client waits for the Content-Length bytes until its own timeout.
epoll: makeWriteBodyFn (engine/epoll/loop.go) returns without writing or erroring when cs.pendingBytes + len(cs.bodyBuf) + len(body) > cs.writeCap(), and makeWriteFn does the same. writeCap() is maxPendingBytes = 4 MiB for an H1/H2 connection (engine/epoll/conn.go). The headers are already staged, so they go out, the body never does, and the pending > cs.writeCap() close in drainRead never fires because nothing was added. io_uring has the same 4 MiB cap, maxSendQueueBytes (engine/iouring/conn.go), and the same result. Its code path has not been traced here. std delivers the response.
The cap is meant to stop a stalled peer from filling memory. Here it drops a response that was never pending, and it does so silently.
Measurement
One request per connection, the handler writing c.Blob at once, no shutdown, a raw keep-alive client reading until the body is complete, EOF or 10 s. REPRO-BIG lines, main 698bed6:
| engine |
1 MiB |
4 MiB - 4 KiB |
4 MiB |
| std |
complete |
complete |
complete |
| epoll |
complete |
complete |
200 OK, 121 header bytes, 0 body bytes, still open at 10 s |
| io_uring |
complete |
complete |
200 OK, 121 header bytes, 0 body bytes, still open at 10 s |
The review of #746 got the same result with net/http's client (http_body=0, context deadline exceeded after 10 s) and with a Connection: close raw client (140 bytes, then EOF).
Script: lane evidence lanes-20260927/LIFECYCLE/round2/repro/run-repro.sh <ref> (TestReproBigResponse). Log: round2/logs/repro-698bed6-unlimited.log.
Fix direction
Stage a single response larger than the cap, since it is not a stalled peer's backlog, and let the back-pressure check apply to what stays unsent. Or, if a hard per-response limit is wanted, close the connection (or answer 500 before the headers) instead of dropping the body. Either way, never leave a connection open with a declared body that will not come. Test: 4 MiB and 64 MiB bodies on every engine, keep-alive and Connection: close.
Found while reviewing #746; not related to shutdown. Pre-existing on main: measured on 698bed6.
Defect
On epoll and io_uring, an HTTP/1.1 response whose body is 4 MiB or more is sent as its headers only. The body is dropped silently, the connection stays open, and a keep-alive client waits for the
Content-Lengthbytes until its own timeout.epoll:
makeWriteBodyFn(engine/epoll/loop.go) returns without writing or erroring whencs.pendingBytes + len(cs.bodyBuf) + len(body) > cs.writeCap(), andmakeWriteFndoes the same.writeCap()ismaxPendingBytes= 4 MiB for an H1/H2 connection (engine/epoll/conn.go). The headers are already staged, so they go out, the body never does, and thepending > cs.writeCap()close indrainReadnever fires because nothing was added. io_uring has the same 4 MiB cap,maxSendQueueBytes(engine/iouring/conn.go), and the same result. Its code path has not been traced here. std delivers the response.The cap is meant to stop a stalled peer from filling memory. Here it drops a response that was never pending, and it does so silently.
Measurement
One request per connection, the handler writing
c.Blobat once, no shutdown, a raw keep-alive client reading until the body is complete, EOF or 10 s.REPRO-BIGlines, main 698bed6:The review of #746 got the same result with net/http's client (
http_body=0,context deadline exceededafter 10 s) and with aConnection: closeraw client (140 bytes, then EOF).Script: lane evidence
lanes-20260927/LIFECYCLE/round2/repro/run-repro.sh <ref>(TestReproBigResponse). Log:round2/logs/repro-698bed6-unlimited.log.Fix direction
Stage a single response larger than the cap, since it is not a stalled peer's backlog, and let the back-pressure check apply to what stays unsent. Or, if a hard per-response limit is wanted, close the connection (or answer 500 before the headers) instead of dropping the body. Either way, never leave a connection open with a declared body that will not come. Test: 4 MiB and 64 MiB bodies on every engine, keep-alive and
Connection: close.