[BUG] Fix transient would-block handling in the ext/http embedded server#4283
[BUG] Fix transient would-block handling in the ext/http embedded server#4283thc1006 wants to merge 4 commits into
Conversation
a81d990 to
d294ce8
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4283 +/- ##
==========================================
+ Coverage 81.24% 81.25% +0.02%
==========================================
Files 446 446
Lines 18872 18877 +5
==========================================
+ Hits 15330 15337 +7
+ Misses 3542 3540 -2
🚀 New features to boost your workflow:
|
171b8e0 to
84d5411
Compare
|
A note on the Codecov result, since it is the first thing a reviewer will see on this PR. The two uncovered lines are both inside the new There are two ways to cover it and I am fine with either:
My own preference is to leave the PR as it stands and treat the gap as known, because the uncovered part is a two line reaction to a kernel state rather than logic that can be wrong in an interesting way. I am happy to add either variant if you would rather have one, and happy to hear that a third approach is better. One other thing worth flagging while this is open. Reading the rest of the file for this fix turned up several further problems, including what looks like a use-after-free when a handler returns |
|
The macOS job here is red on It fails as a CTest timeout rather than an assertion, and Four of my other open PRs passed the same job today, two of them in a window that overlaps a failing run, so a bad runner period does not look like the explanation either. I have also seen this test time out on the pre-rebase revision of this PR and on the tsan job. I cannot re-run jobs on this repository. Happy to rebase if a clean run would be more useful than my word for it, although the branch is only behind by dependabot commits. |
2aad392 to
74e19df
Compare
The embedded HttpServer mishandled nonblocking send()/recv() results: - sendMore() fell through to sendBuffer.erase(0, sent) when send() returned -1 (EWOULDBLOCK). With sent == -1 the erase count becomes SIZE_MAX and the whole unsent response is wiped. It now never erases on a failed send, and re-registers for writability on a would-block so the buffer is kept. Fixes open-telemetry#4281. - onSocketReadable() closed the connection on any recv() <= 0, including the transient -1/EWOULDBLOCK case. It now closes only on an orderly shutdown (recv() == 0) or a real error, and ignores a transient would-block. Fixes open-telemetry#4282. - Both paths capture the socket error immediately after send()/recv(), before LOG_TRACE can clobber errno / the Winsock last error. EWOULDBLOCK and EAGAIN have the same value on all supported platforms, so a single ErrorWouldBlock check is used (checking both is redundant and trips clang-tidy misc-redundant-expression). Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
addSocket() replaces the reactor interest set rather than adding to it, so the would-block path added in this PR leaves a socket registered for writability only. Every other sending state either keeps sending or re-registers Readable once it finishes, but Sending100Continue moved to ReceivingBody without doing so and then returned to wait for a request body it could no longer be woken for. On Linux that is a busy loop on a level-triggered EPOLLOUT with the body never read: an epoll probe registering only EPOLLOUT reports EPOLLOUT on every wait and never EPOLLIN, even with readable bytes already pending. On Windows there is no further FD_WRITE, so the request stalls. The trigger is narrow because it needs a 25 byte interim response to block on a fresh connection, but the interest set is now restored explicitly rather than by luck. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The PR title was narrowed to "transient would-block handling" but the CHANGELOG line still read "Fix nonblocking send/recv handling", which overstates the scope: fatal-send cleanup, EINTR, Windows short writes, SIGPIPE, and the macOS reactor bugs remain and are tracked in open-telemetry#4287. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
d5c67b8 to
1ae6969
Compare
Fill a nonblocking socket's kernel send buffer, then call sendMore() on a connection with an unsent response so send() returns EWOULDBLOCK. The test asserts the response is kept and sendMore() reports more to send; the old code reached erase(0, sent) with sent == -1, wiping the buffer. Verified fail-before (the pre-fix header fails the assertions) and pass-after. The test reaches the protected sendMore()/Connection through a subclass: under the level-triggered reactor a single send() after a readiness event returns a positive count, so the would-block branch is otherwise only reached from the readable path on a backlogged keep-alive connection, which is hard to stage deterministically end to end. POSIX-only, since it relies on socketpair(). Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Fixes #4281
Fixes #4282
Refs #4287
Two robustness bugs in the
ext/httpembedded server's would-block handling, both found while reviewing #4280, plus a third that review of this PR turned up in the fix itself.sendMore()drops the response on write backpressure (#4281)When
send()returns-1withEWOULDBLOCK, the old code skipped the error early-return and fell through tosendBuffer.erase(0, sent); withsent == -1the count converts toSIZE_MAXand wipes the whole unsent response. It now never erases onsent < 0, and re-registers the socket for writability on a would-block so the buffer is kept and retried.onSocketReadable()closes on transient recv errors (#4282)The old code closed the connection on any
recv() <= 0. For a nonblocking socket,recv() == -1/EWOULDBLOCKis transient, not a close. It now closes only on an orderly shutdown (recv() == 0) or a real error, and ignores a transient would-block.Restoring read interest after an asynchronous 100 Continue
This one came out of review of this PR, and it was introduced by the first fix above.
Reactor::addSocket()replaces the interest set rather than adding to it, so the new would-block path leaves a socket registered for writability only. Every other sending state either keeps sending or re-registersReadableonce it finishes, butSending100Continuemoved toReceivingBodywithout doing so and then returned to wait for a request body it could no longer be woken for. The old code avoided this by accident: it wiped the buffer on would-block, so it never reached theaddSocket()call at all.I checked the Linux half with an epoll probe registering only
EPOLLOUTon a socketpair that already had readable bytes waiting:Every wait returns immediately with
EPOLLOUTand neverEPOLLIN, so the connection would spin without ever reading the body. On Windows there would be no furtherFD_WRITEand the request would simply stall.The trigger is narrow, since it needs a 25 byte interim response to block on a fresh connection, but the interest set is now restored explicitly instead of by luck.
Error code capture ordering
Both paths read the socket error immediately after
send()/recv(), beforeLOG_TRACEcan clobbererrnoor the Winsock last error.On EAGAIN vs EWOULDBLOCK
An earlier revision also checked
EAGAIN, butEAGAINandEWOULDBLOCKhave the same value on every platform this repo builds on (Linux, macOS; Winsock has onlyWSAEWOULDBLOCK), so checking both is redundant and trips clang-tidymisc-redundant-expression. A singleErrorWouldBlockcheck is therefore used. POSIX does permit the two to differ, so this is a statement about the platforms here rather than a general one.What this PR does not fix
All tracked in #4287, and I have narrowed the title so this PR does not read as a complete fix of the nonblocking write state machine:
sendMore()still performs a singlesend()per readiness event. Winsock permits a nonblockingsend()to succeed having transferred only part of the buffer, and only guarantees a furtherFD_WRITEafter a send has failed withWSAEWOULDBLOCK. Re-registering does not help, sinceaddSocket()is a no-op when the flag set is unchanged. A positive short write on a large response can therefore stall. The fix is a drain loop and it deserves its own PR.send()errors still returntruewithout closing the connection, removing the reactor registration or changing state.EINTRis not classified as retryable.static_cast<int>(conn.sendBuffer.size())narrows for a handler-produced body nearINT_MAX.SIGPIPEis [BUG] Embedded HTTP server writes can raise SIGPIPE and terminate the process #4293, with PR [BUG] Stop embedded HTTP server writes from raising SIGPIPE #4294 up. That one is worth landing early, since otherwise the process can die before any of the fatal-error handling above is reached.On the missing coverage
Codecov reports three uncovered lines: the would-block re-register in
sendMore(), the transient-would-block branch inonSocketReadable(), and the read-interest restore afterSending100Continue.The
sendMore()would-block line is now covered byext/test/http/http_server_test.cc. I earlier argued that branch could not be reached before the drain-loop follow-up; that was wrong and I withdraw it. It is not reachable from the writable callback, since level-triggeredEPOLLOUTonly fires once the kernel has space and that singlesend()returns a positive count. It is reachable from the readable path: on a backlogged keep-alive connection whose peer has stopped reading, the kernel send buffer stays full, so the next request driveshandleConnection(), thensendMore(), thensend()toEWOULDBLOCKon the same call stack. The test constructs that state directly, filling a nonblocking socket's send buffer and callingsendMore(), and asserts the response is kept rather than wiped byerase(0, -1). It fails against the pre-fix header and passes against this one.The other two lines are harder to reach without touching the header. The
onSocketReadable()would-block branch is inside a private reactor callback and, under level-triggeredEPOLLIN, only runs on a spurious wakeup, so reaching it needs a test seam I did not want to add to an installed header for this scoped fix. TheSending100Continuerestore needs the 25 byte interim response to block on a fresh connection, which I have not found a way to force deterministically across the three CI platforms. Both are guards for transient states; if you would like them locked down as well I will add a protected seam or a gray-boxhandleConnection()test.Verification
ext/test/http/http_server_test.ccpasses under ASan and UBSan, and was verified fail-before against the pre-fix header (both assertions fail there)..clang-tidyonhttp_server.h: the same pre-existingmodernize-use-emplacewarnings before and after, and the fix adds none. This PR does not changewarning_limit.