Skip to content

[BUG] Fix transient would-block handling in the ext/http embedded server#4283

Open
thc1006 wants to merge 11 commits into
open-telemetry:mainfrom
thc1006:codehealth/ext-http-nonblocking-io-bugs
Open

[BUG] Fix transient would-block handling in the ext/http embedded server#4283
thc1006 wants to merge 11 commits into
open-telemetry:mainfrom
thc1006:codehealth/ext-http-nonblocking-io-bugs

Conversation

@thc1006

@thc1006 thc1006 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #4281
Fixes #4282
Refs #4287

Two robustness bugs in the ext/http embedded 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 -1 with EWOULDBLOCK, the old code skipped the error early-return and fell through to sendBuffer.erase(0, sent); with sent == -1 the count converts to SIZE_MAX and wipes the whole unsent response. It now never erases on sent < 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/EWOULDBLOCK is 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-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. The old code avoided this by accident: it wiped the buffer on would-block, so it never reached the addSocket() call at all.

I checked the Linux half with an epoll probe registering only EPOLLOUT on a socketpair that already had readable bytes waiting:

  registered EPOLLOUT only; peer has 17 readable bytes waiting
    wait 0: n=1 events=0x4  EPOLLIN=no EPOLLOUT=yes
    wait 1: n=1 events=0x4  EPOLLIN=no EPOLLOUT=yes
    wait 2: n=1 events=0x4  EPOLLIN=no EPOLLOUT=yes

Every wait returns immediately with EPOLLOUT and never EPOLLIN, so the connection would spin without ever reading the body. On Windows there would be no further FD_WRITE and the request would simply stall.

The interest set is now restored explicitly on the Sending100Continue -> ReceivingBody transition 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(), before LOG_TRACE can clobber errno or the Winsock last error.

On EAGAIN vs EWOULDBLOCK

An earlier revision also checked EAGAIN, but EAGAIN and EWOULDBLOCK have the same value on every platform this repo builds on (Linux, macOS; Winsock has only WSAEWOULDBLOCK), so checking both is redundant and trips clang-tidy misc-redundant-expression. A single ErrorWouldBlock check 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:

  • Windows short writes. sendMore() still performs a single send() per readiness event. Winsock permits a nonblocking send() to succeed having transferred only part of the buffer, and only guarantees a further FD_WRITE after a send has failed with WSAEWOULDBLOCK. Re-registering does not help, since addSocket() 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.
  • Fatal send() errors still return true without closing the connection, removing the reactor registration or changing state.
  • EINTR is not classified as retryable.
  • static_cast<int>(conn.sendBuffer.size()) narrows for a handler-produced body near INT_MAX.
  • SIGPIPE is [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:

  • WouldBlockPreservesTheSendBuffer fills a nonblocking socket's send buffer, calls the protected sendMore(), and asserts the response is kept rather than wiped by erase(0, -1) and that the socket is re-armed Writable | Closed. On the normal single-threaded Linux level-triggered path this branch is not expected from the writable callback (EPOLLOUT fires once the kernel has space, so that send() returns a positive count); it is reached from the readable path on a backlogged keep-alive connection, which the test constructs directly.
  • WouldBlockKeepsTheConnection drives the private onSocketReadable() override through the reactor's public SocketCallback reference on a nonblocking socket with no data, and asserts the transient EWOULDBLOCK leaves the connection registered and the socket open.
  • CompletedInterimResponseRestoresReadInterest stages a completed Sending100Continue and calls the protected handleConnection(), asserting the transition to ReceivingBody restores Readable | Closed.

The two reactor-callback tests reach private and protected HttpServer state through a test subclass and the existing public Reactor::SocketCallback interface, so no production header seam was added.

Verification

  • ext/test/http/http_server_test.cc passes 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-format 18 clean.
  • clang-tidy 18 with the repo .clang-tidy on http_server.h: the same pre-existing modernize-use-emplace warnings before and after, and the fix adds none. This PR does not change warning_limit.
  • The epoll behavior quoted above was measured rather than assumed.

@thc1006
thc1006 requested a review from a team as a code owner July 23, 2026 20:44
Copilot AI review requested due to automatic review settings July 23, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thc1006
thc1006 force-pushed the codehealth/ext-http-nonblocking-io-bugs branch from a81d990 to d294ce8 Compare July 23, 2026 20:45
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.27%. Comparing base (29d7a75) to head (640ae45).

Additional details and impacted files

Impacted file tree graph

@@            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     
Files with missing lines Coverage Δ
...nclude/opentelemetry/ext/http/server/http_server.h 67.08% <100.00%> (+1.17%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006
thc1006 force-pushed the codehealth/ext-http-nonblocking-io-bugs branch 3 times, most recently from 171b8e0 to 84d5411 Compare July 24, 2026 15:00
@thc1006

thc1006 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Update (cc75666): the two lines this note originally flagged are now covered. HttpServerSendMoreTest.WouldBlockPreservesTheSendBuffer drives the sendMore() would-block branch directly (the addSocket() re-arm and the early return true), and the recv-would-block and 100-Continue branches have their own deterministic tests, so Codecov now reports 100% patch coverage — no socket_tools.h test seam was needed.

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 -1. They are collected with line references in #4287, and the memory-safety one has its own issue in #4288. None of them are in scope here, and this PR does not depend on any of them.

@thc1006 thc1006 changed the title [BUG] Fix nonblocking send/recv handling in the ext/http embedded server [BUG] Fix transient would-block handling in the ext/http embedded server Jul 24, 2026
@thc1006

thc1006 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

The macOS job here is red on config.ProgrammaticConfigTest.EnabledConfigProducesProviders, and I do not think it is related to this change.

It fails as a CTest timeout rather than an assertion, and sdk/test/configuration/programmatic_configuration_test.cc contains no reference to http_server.h or anything else under ext/http, so the header this PR touches is not in that test binary. The two failing runs took 33 minutes against 9 to 12 for the ones that passed, which fits the timeout itself being what consumed those minutes.

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.

@thc1006
thc1006 force-pushed the codehealth/ext-http-nonblocking-io-bugs branch from 2aad392 to 74e19df Compare July 24, 2026 17:52
thc1006 added 3 commits July 25, 2026 16:58
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>
@thc1006
thc1006 force-pushed the codehealth/ext-http-nonblocking-io-bugs branch from d5c67b8 to 1ae6969 Compare July 25, 2026 09:00
thc1006 added 8 commits July 25, 2026 17:06
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants