[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 17 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 80.84% 80.89% +0.05%
==========================================
Files 448 448
Lines 19161 19168 +7
==========================================
+ Hits 15489 15504 +15
+ Misses 3672 3664 -8
🚀 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
…onblocking-io-bugs # Conflicts: # CHANGELOG.md # ext/test/http/BUILD
…onblocking-io-bugs # Conflicts: # CHANGELOG.md
Every case here drives a POSIX socketpair and sits inside #ifndef _WIN32, so the Windows build contained no tests and passed with nothing run. open-telemetry#4294 hit the same thing and solved it this way, so this follows it: one case declared on every platform, which also gives gtest_add_tests, which scans the source, a name the binary contains everywhere.
It sat between open-telemetry#4298 and open-telemetry#4292, which breaks the run the rest of the list follows.
1d7e6d8 to
c0b716a
Compare
POSIX lets a nonblocking send() or recv() report either EAGAIN or EWOULDBLOCK and does not require the two constants to be equal. Where they differ, recv() reporting EAGAIN closed a healthy connection and send() reporting it was never re-armed for writability, so the response stalled. The comparison moves into Socket::IsWouldBlock(). The preprocessor picks the branch, so a platform where the two are equal still compiles a single comparison and clang-tidy has no redundant expression to report, while a platform where they differ accepts both. ErrorWouldBlock stays, since it is part of the public surface of an installed header. The test uses the same helper rather than repeating the errno policy.
|
Closing following @dbarker's guidance on #4287: |
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
POSIX lets a nonblocking
send()orrecv()report either, and does not require the two constants to be equal. Where they differ,recv()reportingEAGAINclosed a healthy connection andsend()reporting it was never re-armed for writability.Both are now classified by
Socket::IsWouldBlock():The preprocessor picks the branch, which is what makes this workable: an earlier revision wrote both comparisons unconditionally and clang-tidy reported
misc-redundant-expressionon every platform where the constants are equal, which is all of the ones CI builds. Measured onall-options-abiv1-preview,socket_tools.hcarries the same five warnings as it does onmainand nomisc-redundant-expression.ErrorWouldBlockstays: it is part of the public surface of an installed header. The test uses the helper rather than repeating the errno policy, andSocketToolsTests.RecognizesWouldBlockErrorspins thatEPIPEis not classified as would-block, which areturn truewould otherwise satisfy.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.