socket:send/2,3: distinguish eagain from closed, and retry transparently via write-select#2345
socket:send/2,3: distinguish eagain from closed, and retry transparently via write-select#2345harmon25 wants to merge 8 commits into
Conversation
Signed-off-by: harmon25 <dougwright1@gmail.com>
|
thank you for pushing the httpd side! this is obviously correct - however it does increase the leakage of underlying implementation, and now any caller will also need to implement their own unique retry mechanism (gen_tcp_socket.erl, socket_dist_controller.erl, ssl.erl - and atomvm_lib httpd) - and is it 5ms with exponential backoff or xyz - and we end up with complexity, duplicated and slow (sleeping) code.. I believe the solution is to implement nif_select_write/2, with proper write-select and no sleeping/guessing, eg. handle the happy path out of the box, similar to otp: see https://github.com/petermm/AtomVM/tree/nif_select_write/2 (based on this PR) |
|
Thanks @petermm! I agree, that sounds like a better approach. Should we continue from your branch, or should i incorporate those changes here? |
enif_select_write was declared in erl_nif.h but never implemented or called anywhere. This implements it, and reworks enif_select_common so a resource can hold independent pending read and write selects (each with its own ref/message/pid) on the same event at the same time, instead of one direction silently overwriting the other's state when both are selected concurrently (e.g. a socket with a permanent recv select and a transient send-backpressure select). Also fixes a ref-ticks computation bug: guard with term_is_local_reference(ref) instead of ref != UNDEFINED_ATOM before calling term_to_ref_ticks, since enif_select_read/enif_select_write pass UNDEFINED_ATOM (not a ref) when a custom message is used, which was previously (harmlessly, but incorrectly) treated as if it were a reference on some architectures. Adds tests/test-enif.c coverage for independent read/write select state sharing the same event, with both the custom-message and generic ref-only enif_select paths. Signed-off-by: harmon25 <dougwright1@gmail.com>
Wires up write-readiness selection for sockets on top of the new
enif_select_write:
- BSD: nif_select_read/nif_select_write share a common
nif_socket_select(is_write) implementation; write selects go through
enif_select_write, mirroring the existing read path. Track whether
each direction is actively selecting via explicit read_selecting/
write_selecting booleans (a ref of 0 is ambiguous between "not
selecting" and "selecting with an undefined ref"), so nif_socket_close
notifies exactly the direction(s) that were actually armed.
- lwIP: adds a new SocketStateSelectingWrite bit (and composite
TCPSelectingWrite/TCPSelectingReadWrite states) alongside the existing
read state, so a TCP socket can be selecting for read and write at the
same time. Registers tcp_sent/tcp_poll callbacks to detect when the
send buffer has room again (lwip_tcp_can_send, based on tcp_sndbuf/
tcp_sndqueuelen) and fire the write-select notification; tcp_poll is
used as a fallback wakeup for paths that wouldn't otherwise trigger
tcp_sent.
- socket_make_select_notification, send_closed_notification and the
select_event_send_notification_from_nif/handler helpers now take an
is_write parameter to use the correct ref/state for each direction.
nif_socket_close and nif_socket_select_stop notify/clear both
directions independently instead of assuming read-only.
- Registers nif_select_write/2 in the socket NIF table.
This is infrastructure for socket:send/2 to wait for write-readiness
instead of leaking {error, eagain} to callers (next commit).
Signed-off-by: harmon25 <dougwright1@gmail.com>
socket:send/2 and sendto/3 now retry partial sends and transient send
backpressure ({error, eagain}) internally, waiting for the socket to
become writable again via the new nif_select_write/2, instead of
returning {ok, Rest} or {error, eagain} to the caller. Both now return
ok | {error, Reason}, matching real OTP socket semantics and removing
the need for every caller to implement its own retry/backoff loop.
Signed-off-by: harmon25 <dougwright1@gmail.com>
- ssl.erl: replace the 4 busy-loops on want_write ("We're currently
missing non-blocking writes") with a real wait_write/1 that selects
for write-readiness via socket:nif_select_write/2, used by the TLS
handshake, close-notify, send and recv loops.
- socket_dist_controller.erl: handle_cast(tick, ...) and send_data_loop/1
now check socket:send/2's result and stop the connection with
{send_error, Reason} instead of silently ignoring send failures.
- epmd.erl: assert ok = socket:send(...) for the one call site that
didn't already, matching the others.
- Examples (picow_tcp_server.erl, tcp_socket_server.erl): drop the now
impossible {ok, Rest} branch after socket:send/2.
Signed-off-by: harmon25 <dougwright1@gmail.com>
Fills the client's send buffer via the raw nif_send/2 (bypassing
socket:send/2's automatic retry) against a peer that never reads, until
real backpressure ({error, eagain}) is observed or a generous attempt
budget is exhausted (different platforms/kernels size socket buffers
differently, so never observing backpressure is tolerated rather than
failing the test). When backpressure is observed, verifies that
socket:nif_select_write/2 correctly waits for the socket to become
writable again once the peer drains its receive buffer, and that
socket:send/2 completes successfully afterwards instead of leaking
{error, eagain} or {ok, Rest}.
Note the peer must fully drain (not just partially) to reliably observe
write-readiness again: depending on platform/kernel socket buffer
accounting, a small partial drain does not necessarily cross the
low-water mark for writability, as observed empirically in this sandbox
even with a plain C reproduction using raw poll(2) (independent of any
AtomVM code).
Signed-off-by: harmon25 <dougwright1@gmail.com>
Signed-off-by: harmon25 <dougwright1@gmail.com>
|
@petermm Thanks for the review and for prototyping this on
Verified: One thing worth calling out from writing the test: I initially had it drain only a small chunk (64KB) of the peer's backlog and expected the socket to become writable again, but that hung — traced it down (gdb backtraces + an independent raw C |
socket:sendto/3 no longer returns {ok, Rest} now that sends are
retried internally on backpressure (see e0e4154 for send/2's
callers); udp_socket_client.erl was missed since it calls sendto/3
rather than send/2. Dialyzer flagged the branch as unreachable.
Signed-off-by: harmon25 <dougwright1@gmail.com>
This pull request improves the accuracy of error reporting in the socket send implementation, and adds a proper write-select mechanism so that transient send backpressure is retried transparently instead of being leaked to callers.
Socket send error handling improvements:
src/libAtomVM/otp_socket.c:do_socket_sendnow distinguishes transient backpressure (such as lwIPERR_MEMor BSDEAGAIN/EWOULDBLOCK) from a closed connection at the NIF level.src/libAtomVM/otp_socket.c: When the peer has closed the connection, returns{error, closed}instead of{ok, Data}to clarify the connection state.Write-select mechanism (
enif_select_write):enif_select_writewas declared inerl_nif.hbut never implemented or called anywhere. It's now implemented, andenif_select/enif_select_read/enif_select_writewere reworked so a resource can hold independent pending read and write selects (each with its own ref/message/pid) on the same event at the same time, instead of one direction silently overwriting the other's state. This is needed since a socket can have a permanent recv select active (e.g.socket_dist_controller) at the same time as a transient send-backpressure select. Along the way, fixed a latent ref-ticks computation bug (term_is_local_reference(ref)vsref != UNDEFINED_ATOM). Added C-level unit test coverage intests/test-enif.c(there was none forenif_selectat all before).otp_socket.cnow implementsnif_select_write/2for both BSD (enif_select_write) and lwIP (newtcp_sent/tcp_pollcallbacks + aSocketStateSelectingWritestate, usingtcp_sndbuf/tcp_sndqueuelento detect when the send buffer has room again).socket:send/2,3now retries partial sends and{error, eagain}internally by waiting on the newnif_select_write, so callers getok | {error, Reason}— no more leaking{ok, Rest}/{error, eagain}and no need for every caller to implement its own retry/backoff loop.ssl.erl's handshake/close-notify/send/recv loops now wait for real write-readiness instead of busy-looping onwant_write.socket_dist_controller.erlnow terminates the distribution connection onsocket:send/2errors (tick + outgoing data loop) instead of silently ignoring them.test_tcp_socket.erl: fills the send buffer via rawnif_send/2until genuineEAGAIN, then verifiesnif_select_write/2correctly wakes up once the peer drains, and thatsocket:send/2completes transparently afterward.Documentation update:
CHANGELOG.md: Updated to describe both the eagain/closed distinction and the new internal retry behavior.Testing
Validated on real ESP32-S3 hardware (not host/qemu) using
atomvm_httpd, a consumer library whosetcp_server:do_send/4retries sends specifically keyed on theeagainvs
closedreason (src/tcp_server.erl#L407) — exactly the distinction this PR introduces at the NIF level.Setup: flashed this branch's VM firmware, then ran the library's
httpd_debugexample with its send-retry logging temporarily enabled (?TRACEondo_send/retry_send), so every retry logs the literal error reason returned bysocket:send/2.Reproducing genuine backpressure: a naive high-concurrency/full-speed
curlburst against a large generated response raced the client's own timeout (the connection was genuinely closed before the server got scheduled to send) rather than exercising real send backpressure. Reliable backpressure instead came from a slow-reading client (curl --limit-rate 1k) against a 64KB response, which fills the small lwIP TCP send buffer because the peer isn't draining it, without racing timeouts:Result: 3 concurrent slow connections produced 12 retry events, all logged as
Send error eagain; retry 0— zeroSend error closed— and every connection completed successfully (200, full 65646-byte body) after a single retry each time. This confirms:ERR_MEMbackpressure now surfaces distinctly as{error, eagain}(previously indistinguishable from{error, closed}).sendattempt succeeded every time.The write-select mechanism added on top of this was additionally verified with
test-enif(C-levelenif_selectunit tests), the fulltest_estdlibsuite (including the new backpressure test) on generic_unix, and a full ESP32 (esp32s3) build via ESP-IDF 5.5.4.These changes are made under both the "Apache 2.0" and the "GNU Lesser General
Public License 2.1 or later" license terms (dual license).
SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later