Skip to content

socket: distinguish eagain from closed, and retry transparently via write-select - #2345

Open
harmon25 wants to merge 7 commits into
atomvm:release-0.7from
harmon25:eagain_tcp
Open

harmon25 wants to merge 7 commits into
atomvm:release-0.7from
harmon25:eagain_tcp

Conversation

@harmon25

@harmon25 harmon25 commented Jun 30, 2026 •

Copy link
Copy Markdown

Distinguish transient send backpressure from a closed connection in socket:send/2,3, and wait for write-readiness with a proper write select instead of leaking {error, eagain} or {ok, Rest} to callers.

Ran into this building httpd, a fork/standalone implementation of atomvm_lib/httpd: large or many subsequent requests failed with the socket closing early, because lwIP ERR_MEM backpressure was reported as {error, closed}.

Commits

  1. Fix socket send reporting backpressure as closed: lwIP ERR_MEM / BSD EAGAIN|EWOULDBLOCK is now {error, eagain}, and a closed connection is {error, closed} instead of {ok, Data}.
  2. Add enif_select_write and ERL_NIF_SELECT_CANCEL: enif_select_write was declared but never implemented. A select event now keeps independent read and write state (ref, message, pid), so a resource can have a pending read and a pending write select at the same time. ERL_NIF_SELECT_CANCEL (with the *_CANCELLED flags) cancels one direction and keeps the other armed. Also fixes the ref ticks computation. C tests in tests/test-enif.c.
  3. otp_socket: Add nif_select_write/2: BSD uses enif_select_write; lwIP uses tcp_sent/tcp_poll and tcp_sndbuf/tcp_sndqueuelen. A socket has one selecting process and monitor per direction, like resources.c: select replaces only its own direction, close and select_stop handle both, socket_down releases only the dead owner's direction (and no longer aborts the lwIP connection, like BSD), aborts go to each direction's own pid, and the lwIP handlers notify the owner of their direction. The selecting state is initialized by one helper shared by open and accept. socket:nif_select_read_stop/1 stops only the read direction when another process is selecting for write, and is used by the read/accept error cleanup paths so that a failed read does not disown a blocked writer. On lwIP, tcp_sent events are only queued while a write select is pending, lwip_tcp_can_send is called with the lwIP lock held and only on connected sockets, and every pcb callback is detached before a pcb is closed so none can run against a freed resource.
  4. socket: Retry sends on backpressure: socket:send/2 and sendto/3 wait on nif_select_write/2 and retry, so callers get ok | {error, Reason} (or {error, {Reason, RestData}} after a partial send, as in OTP); empty payloads still go through the NIF like before.
  5. Change socket:send callers to the new semantics: ssl waits for write-readiness instead of busy looping on want_write; socket_dist_controller terminates the connection when socket:send/2 fails; dead {ok, Rest} branches are removed.
  6. Add tests: send backpressure with write select; closing an accepted socket selected by another process (valgrind flags the uninitialized state without the fix); close with a blocked reader and a blocked writer; killing the writer leaves the reader working.
  7. Document socket write select in the changelog.

Known limitations

Testing

  • test-enif (including the new ERL_NIF_SELECT_CANCEL tests) and the test_estdlib suite on generic_unix, including test_tcp_socket under valgrind. test_serial_dist_socat fails in the same way on a plain release-0.7 build here (socat/pty environment).
  • New tests: a reader cleaning up after the peer half-closes must not disown a blocked writer (fails without nif_select_read_stop), and empty send/2/sendto/3 payloads (also run on BEAM).
  • The new socket tests fail on the single owner implementation (the reader hangs) and pass now.
  • Every commit builds and test-enif passes on each. ESP32 (ESP-IDF 5.5.4) build, and the lwIP code path syntax-checked against ESP-IDF's lwIP headers: not run on RP2 hardware.
  • Earlier on real ESP32-S3 hardware with atomvm_httpd: 3 concurrent slow readers (curl --limit-rate 1k against a 64KB response) logged only eagain retries (never closed) and every response completed with the full body. That run predates the review changes below.

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

@harmon25
harmon25 marked this pull request as ready for review July 1, 2026 19:47
@petermm

petermm commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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)

@harmon25

harmon25 commented Jul 4, 2026

Copy link
Copy Markdown
Author

Thanks @petermm! I agree, that sounds like a better approach.

Should we continue from your branch, or should i incorporate those changes here?

@harmon25

harmon25 commented Jul 4, 2026

Copy link
Copy Markdown
Author

@petermm Thanks for the review and for prototyping this on nif_select_write/2 — agreed this is the right fix, and I've implemented it on top of this branch (6 new commits, keeping the existing eagain/closed distinction as the low-level primitive underneath):

  1. enif_select_write — was declared in erl_nif.h but never implemented or called anywhere. Implemented it, and reworked enif_select/enif_select_read/enif_select_write 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 (needed since a socket can have a permanent recv select active at the same time as a transient send-backpressure select, e.g. socket_dist_controller). Also fixed a latent ref-ticks bug found along the way (using term_is_local_reference(ref) instead of ref != UNDEFINED_ATOM). Added C-level unit test coverage for this in tests/test-enif.c (there was none before for enif_select at all).
  2. otp_socket.c — wired up nif_select_write/2 for both BSD (enif_select_write) and lwIP (new tcp_sent/tcp_poll callbacks + a SocketStateSelectingWrite state, using tcp_sndbuf/tcp_sndqueuelen to detect room again).
  3. socket:send/2,3 — now retries partial sends and {error, eagain} internally by waiting on nif_select_write, so callers just see ok | {error, Reason} — no more leaking {ok, Rest}/{error, eagain}.
  4. ssl.erl — replaced the 4 want_write busy-loops with a real wait-for-writable.
  5. socket_dist_controller.erl — now actually terminates the connection on socket:send/2 errors instead of silently ignoring them (tick + outgoing data loop).
  6. Added a real backpressure test to test_tcp_socket.erl (fills the send buffer via raw nif_send/2 until genuine EAGAIN, then verifies nif_select_write/2 correctly wakes up once the peer drains, and that socket:send/2 completes transparently afterward).

Verified: test-enif, the full test_estdlib suite (including the new backpressure test) on generic_unix, and a full ESP32 (esp32s3) build via ESP-IDF 5.5.4.

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 poll(2) repro, unrelated to any AtomVM code) to the sandbox's kernel/buffer accounting not reliably crossing the write low-water mark on a small partial drain, only on a full drain. Not an AtomVM bug, just a test design fix, but flagging in case it's relevant to anyone else validating this on constrained targets (rp2/esp32) with small buffers — might be worth a similar "drain fully, not partially" pattern if there's ever a device-side test for this.

@harmon25 harmon25 changed the title Improve socket:send/2,3 error handling for transient send backpressure socket:send/2,3: distinguish eagain from closed, and retry transparently via write-select Jul 4, 2026
@harmon25 harmon25 changed the title socket:send/2,3: distinguish eagain from closed, and retry transparently via write-select socket:: distinguish eagain from closed, and retry transparently via write-select Jul 6, 2026
@harmon25 harmon25 changed the title socket:: distinguish eagain from closed, and retry transparently via write-select socket: distinguish eagain from closed, and retry transparently via write-select Jul 6, 2026

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

First of all I apologize for the long wait before this review. Here I have some comments.

I would like to ask you make your branch git history linear without those merges from release-0.7. Ideally your commits should be amended using git commit --fixup and they should sit on top of latest release-0.7. See also GIT_WORKFLOW_AND_HISTORY_STYLE_GUIDE.md

Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/resources.c
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread tests/test-enif.c Outdated
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread libs/estdlib/src/socket_dist_controller.erl
Comment thread CHANGELOG.md Outdated
petermm added a commit to petermm/AtomVM that referenced this pull request Oct 9, 2026
Blocking sends require read and write readiness to coexist on one socket.
Sharing their owner lets a writer replace a reader, misroutes close
notifications, and cancels both directions when one selector exits.

Give each direction its own owner, monitor and reference, initialized
for both opened and accepted sockets. Implement ERL_NIF_SELECT_CANCEL
so selector death cancels only its direction, disposes its pending
message, and leaves the other direction armed.

Retain socket resources while lwIP write/error events are queued and
detach callbacks on close. Notify established-socket waiters of terminal
errors through direction-specific abort messages, independently of the
connect trap.

Keep distribution output event-driven so backpressure cannot block
incoming traffic or ticks. Preserve pending frame suffixes and queue
ticks behind them to maintain byte ordering.

Send empty binaries through the native socket path so closed sockets
still fail and connected UDP sockets transmit zero-length datagrams.

Add regressions for accepted-socket close, writer death, both selector
registration orders, exact-data backpressure recovery, cancellation
cleanup, and empty UDP sends. Move the changelog entry to beta.1.

See also: atomvm#2345
Signed-off-by: Peter M <petermm@gmail.com>
petermm added a commit to petermm/AtomVM that referenced this pull request Oct 9, 2026
Blocking sends require read and write readiness to coexist on one socket.
Sharing their owner lets a writer replace a reader, misroutes close
notifications, and cancels both directions when one selector exits.

Give each direction its own owner, monitor and reference, initialized
for both opened and accepted sockets. Implement ERL_NIF_SELECT_CANCEL
so selector death cancels only its direction, disposes its pending
message, and leaves the other direction armed.

Retain socket resources while lwIP write/error events are queued and
detach callbacks on close. Notify established-socket waiters of terminal
errors through direction-specific abort messages, independently of the
connect trap.

Keep distribution output event-driven so backpressure cannot block
incoming traffic or ticks. Preserve pending frame suffixes and queue
ticks behind them to maintain byte ordering.

Send empty binaries through the native socket path so closed sockets
still fail and connected UDP sockets transmit zero-length datagrams.

Add regressions for accepted-socket close, writer death, both selector
registration orders, exact-data backpressure recovery, cancellation
cleanup, and empty UDP sends. Move the changelog entry to beta.1.

See also: atomvm#2345
Signed-off-by: Peter M <petermm@gmail.com>
@harmon25
harmon25 requested a review from bettio October 10, 2026 18:47
Comment thread tests/test-enif.c Fixed

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's fix the CodeQL error and it is good for merge.

Comment thread tests/test-enif.c Outdated

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I need to apologize, I think I found other issues just before merging.

Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c
Comment thread libs/estdlib/src/socket.erl Outdated
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c
@harmon25

Copy link
Copy Markdown
Author

Pushed fixes for all six comments, rebased on the latest release-0.7. Thanks for the careful review. #2422 touches the same area (nif_select_read_stop/1), so whichever lands second will need a small rebase.

@harmon25
harmon25 requested a review from bettio October 15, 2026 19:05

@bettio bettio left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I need to apologize here, I have one more review round here. Still I believe we are getting super close.

Comment thread src/libAtomVM/otp_socket.c
Comment thread src/libAtomVM/otp_socket.c
Comment thread src/libAtomVM/otp_socket.c
Comment thread tests/test-enif.c Outdated
Comment thread libs/estdlib/src/socket.erl
Comment thread src/libAtomVM/otp_socket.c
Transient send backpressure (lwIP ERR_MEM, BSD EAGAIN/EWOULDBLOCK) fell
through to the generic error branch of nif_socket_send_internal and was
reported as {error, closed}, so callers could not tell a full send
buffer from a peer that went away. Conversely, an actually closed
connection was reported as {ok, Data}, as if nothing had been consumed.

Report backpressure as {error, eagain} and a closed connection as
{error, closed}.

Signed-off-by: harmon25 <dougwright1@gmail.com>
enif_select_write was declared in erl_nif.h but never implemented, and
ERL_NIF_SELECT_CANCEL was documented as unimplemented.

A select event used to hold a single ref, message and pid shared by the
read and the write direction, so selecting one direction silently
overwrote the other. Keep them independent, so a resource can have a
pending read select and a pending write select at the same time, each
with its own owner.

Implement ERL_NIF_SELECT_CANCEL (with the *_CANCELLED result flags) to
cancel a single direction while keeping the event and the other
direction armed.

Also fix the ref ticks computation, that tested ref against
UNDEFINED_ATOM instead of checking it is a local reference.

Signed-off-by: harmon25 <dougwright1@gmail.com>
Add a write select to the socket NIFs, so a process can wait until a
socket can accept more data instead of polling after {error, eagain}.

BSD sockets use enif_select_write. lwIP sockets use the tcp_sent and
tcp_poll callbacks, and tcp_sndbuf/tcp_sndqueuelen to tell when the send
buffer has room again.

A socket has one selecting process and monitor per direction, like
resources.c: selecting in one direction replaces the previous owner of
that direction only, close and select_stop handle both, and socket_down
releases only the direction of the dead process. A reader and a writer
can therefore block on the same socket without disturbing each other,
and an abort is sent to the owner of each direction. On lwIP the
callbacks notify the owner of their direction, and a connect traps its
caller on the write slot.

The selecting state is initialized by a helper shared by all the ways a
socket is created, including accept.

Signed-off-by: harmon25 <dougwright1@gmail.com>
Make socket:send/2 and socket:sendto/3 retry partial sends and
{error, eagain} internally, waiting on nif_select_write/2 for the socket
to become writable again. Callers get ok | {error, Reason} and do not
need their own retry and backoff loop.

Signed-off-by: harmon25 <dougwright1@gmail.com>
socket:send/2 no longer returns {ok, Rest} or {error, eagain}.

- ssl: wait for write readiness in the handshake, close notify, send and
  recv loops instead of busy looping on want_write.
- socket_dist_controller: terminate the connection when socket:send/2
  fails on a tick or on outgoing data, instead of ignoring the error.
- Remove the {ok, Rest} branches that can no longer happen from epmd and
  the examples.

Signed-off-by: harmon25 <dougwright1@gmail.com>
- A write select on a socket with a full send buffer wakes up when the
  peer drains it, and socket:send/2 then completes transparently.
- Closing an accepted socket selected by another process. It would have
  caught accepted sockets not initializing their selecting state, under
  valgrind.
- A process blocked in recv and another blocked in send on the same
  socket are both aborted when a third process closes it.
- When the process blocked in send dies, the one blocked in recv still
  receives data.

Signed-off-by: harmon25 <dougwright1@gmail.com>
Signed-off-by: harmon25 <dougwright1@gmail.com>
@harmon25

Copy link
Copy Markdown
Author

Pushed fixes for all six comments, rebased on the latest release-0.7. Thanks again for the careful review.

@harmon25
harmon25 requested a review from bettio October 16, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants