[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 11 commits into
Conversation
a81d990 to
d294ce8
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4283 +/- ##
==========================================
+ Coverage 81.25% 81.27% +0.03%
==========================================
Files 446 446
Lines 18872 18877 +5
==========================================
+ Hits 15332 15341 +9
+ Misses 3540 3536 -4
🚀 New features to boost your workflow:
|
171b8e0 to
84d5411
Compare
|
Update (cc75666): the two lines this note originally flagged are now covered. Separately, 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>
GCC maintainer mode (-Wlogical-op -Werror) rejected (errno == EWOULDBLOCK || errno == EAGAIN) because the two values are equal on the supported platforms. Use Socket::ErrorWouldBlock, matching the production code. The buffer-fill send() passed MSG_NOSIGNAL, which is not portable to macOS; the peer stays open so no SIGPIPE is possible. Use flags 0. Add <sys/types.h> for ssize_t and socket_tools.h for SocketTools::Socket as direct includes for IWYU. Also assert the would-block path re-arms the socket for Writable | Closed, covering the reactor re-registration and not only buffer preservation.
HttpServerReadableTest.WouldBlockKeepsTheConnection: a readable event on a nonblocking socket with no data returns EWOULDBLOCK; the connection must stay registered and the socket open, not be torn down as end-of-stream. Driven through the public Reactor callback reference, so no production surface is widened. HttpServer100ContinueTest.CompletedInterimResponseRestoresReadInterest: after the interim 100-Continue response drains, the Sending100Continue -> ReceivingBody transition must restore Readable | Closed; the old code left the socket armed Writable-only so the request body was never read. Both verified fail-before/pass-after against the production fixes. Also assert the setsockopt/fcntl results and skip EINTR in the buffer-fill loop.
The http_server_test registration comment was not wrapped to the cmake-format line width; apply cmake-format with the repo config.
Per maintainer guidance, code comments describe what the code does and why, not what a previous revision did. State the required invariant and failure mode rather than 'the old code did X'.
reactorFlags() iterates m_reactor.m_sockets (std::vector) and the readable-path test uses m_connections (std::map), so IWYU (warning_limit 0) requires both headers directly.
… wording WouldBlockKeepsTheConnection also asserts reactorFlags(sock) == Readable | Closed: a transient recv would-block must leave the socket's reactor registration intact, not merely keep the connection in the map with an open fd. Also make three comments precise: stale readiness (not an idle peer) as the would-block cause, the shared HttpServer decision logic (not the whole reactor) as what is platform-independent, and 'not expected on the normal Linux path' rather than 'not reachable'.
…onblocking-io-bugs # Conflicts: # CHANGELOG.md
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 interest set is now restored explicitly on the
Sending100Continue->ReceivingBodytransition rather than left to a later event, and a deterministic test covers it (see Test coverage).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.Test coverage
All three fixed branches have deterministic regression tests in
ext/test/http/http_server_test.cc, each verified fail-before/pass-after against the production header:WouldBlockPreservesTheSendBufferfills a nonblocking socket's send buffer, calls the protectedsendMore(), and asserts the response is kept rather than wiped byerase(0, -1)and that the socket is re-armedWritable | Closed. On the normal single-threaded Linux level-triggered path this branch is not expected from the writable callback (EPOLLOUTfires once the kernel has space, so thatsend()returns a positive count); it is reached from the readable path on a backlogged keep-alive connection, which the test constructs directly.WouldBlockKeepsTheConnectiondrives the privateonSocketReadable()override through the reactor's publicSocketCallbackreference on a nonblocking socket with no data, and asserts the transientEWOULDBLOCKleaves the connection registered and the socket open.CompletedInterimResponseRestoresReadIntereststages a completedSending100Continueand calls the protectedhandleConnection(), asserting the transition toReceivingBodyrestoresReadable | Closed.The two reactor-callback tests reach private and protected
HttpServerstate through a test subclass and the existing publicReactor::SocketCallbackinterface, so no production header seam was added.Verification
ext/test/http/http_server_test.ccpasses under ASan and UBSan; each of the three regression tests was verified fail-before/pass-after against the relevant pre-fix revision (the send- and recv-side tests fail against the original base, and the 100-Continue transition test fails without the explicit read-interest restoration)..clang-tidyonhttp_server.h: the same pre-existingmodernize-use-emplacewarnings before and after, and the fix adds none. This PR does not changewarning_limit.