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 Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread src/libAtomVM/resources.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread tests/test-enif.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread CHANGELOG.md Resolved
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 Resolved

@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 Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread libs/estdlib/src/socket.erl Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
@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 Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
Comment thread tests/test-enif.c Resolved
Comment thread libs/estdlib/src/socket.erl Resolved
Comment thread src/libAtomVM/otp_socket.c Resolved
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

@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.

We are getting close to green light, but there are still some issues.

{reply, socket:send(State#state.socket, Packet), State};
Reply =
case socket:send(State#state.socket, Packet) of
% gen_tcp:send/2 does not report the data that was not sent

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 avoid narration comment, such this one.

// after tcp_shutdown succeeded so that a failed shutdown leaves the
// socket intact, and under the lwIP lock so no callback can run in
// between.
socket_tcp_detach_callbacks(rsrc_obj->tcp_pcb, rsrc_obj->socket_state);

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.

tcp_shutdown(pcb, 1, 1) is lwIP's tcp_close_shutdown(pcb, 1). It frees the pcb and still returns ERR_OK when the pcb is in CLOSED or SYN_SENT state, and in ESTABLISHED/CLOSE_WAIT whenever rcv_wnd != TCP_WND_MAX or refused_data is set (the RST path).
Since tcp_recved only runs when data is copied out in recv (line 2310), any shutdown(S, read_write) with unread data takes that path, and socket_tcp_detach_callbacks then writes callback_arg, recv, sent, poll and err into freed memory.
Detach before tcp_shutdown, as socket_dtor and nif_socket_close do. The only non-freeing failure is ERR_MEM from tcp_close_shutdown_fin, which sets TF_CLOSEPEND so lwIP finishes the close itself; keeping the callbacks detached in that case is fine.

%% Receives at least Remaining bytes (a recv can return more than needed).
recv_at_least(_Socket, Remaining) when Remaining =< 0 ->
ok;
recv_at_least(Socket, Remaining) ->

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.

{recv_failed,timeout,69620736} is thrown here.
Remaining is larger than ?LARGE_PAYLOAD_SIZE, so the server read fewer bytes than Sent, i.e. less than the client kernel had already accepted, then saw nothing for 10 s. That is not writer slowness: queued kernel data cannot stall, so it points at a lost read-select wakeup or an accounting bug specific to that build. The job passed on e2e3ec3 and fails identically on 42a027c and c34bb11, so it tracks the nif_select_read_stop round.
I could not reproduce on 64-bit (native, pinned to one core, valgrind, valgrind --fair-sched=yes); it needs a 32-bit run with the test instrumented before merge.

LWIP_END();
if (can_send) {
// Clear flag to avoid sending a message again.
rsrc_obj->socket_state &= ~SocketStateSelectingWrite;

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.

nif_socket_select updates socket_state under LWIP_BEGIN/END (lines 1283, 1286 and the read branch), while this handler does a read-modify-write of the same word with no lock.
RP2 builds are SMP by default, so a reader doing |= SelectingRead on one core while this clears the write bit on the other loses a bit: the reader is never notified, or a second write notification goes to a consumed ref. tcp_recv_handler has the same pre-existing pattern, but two processes selecting both directions is now the normal case. Move the flag update and the write_select.process_id read inside the locked region.

if (LIKELY(rsrc_obj) && (rsrc_obj->socket_state & SocketStateSelectingWrite)) {
struct LWIPEvent event;
event.handler = tcp_sent_handler;
event.tcp_sent.rsrc_obj = rsrc_obj;

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.

The queued event carries a raw rsrc_obj. Between the enqueue in lwIP context and tcp_sent_handler, socket_down can clear the flag and drop the owner's reference, which can be the last one, so the handler dereferences freed memory. tcp_recv_cb avoids this by queuing tpcb and reading tpcb->callback_arg, which socket_tcp_detach_callbacks NULLs under the lock. Do the same here (also at line 1145), or enif_keep_resource in the callback and release in the handler.

Total = recv_until_closed(ServerSocket, 0),
Owner ! {drained_all, Total},
ok = socket:close(ServerSocket)
after 60000 ->

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.

Under valgrind on a loaded runner, draining 69 MB can exceed this, and the close from another process turns a slow run into a confusing {recv_failed, closed, _} or an abort on the writer. Teardown always sends drain_all, so use infinity or a much larger value.

Reader = spawn(fun() ->
Self ! {recv_result, self(), socket:recv(Client, 0, 30000)}
end),
timer:sleep(100),

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.

Lines 913 and 921 assume the reader and writer are blocked after 100/200 ms, and test_send_error_after_partial_send (lines 871, 876) assumes the writer made progress within 200 ms of the drain, otherwise it gets a plain {error, closed} and fails. Under valgrind on slow CI these are flakiness sources. Have the writer report when it has observed eagain (e.g. fill via raw nif_send in the writer itself before socket:send), and retry the drain until a partial send is observed.

RAISE_ERROR(OUT_OF_MEMORY_ATOM);
}
}
if ((rsrc_obj->socket_state & SocketStateSelectingWrite)

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.

This notifies the process selecting for write, but SocketStateSelectingWrite is only cleared by the SocketStateClosed store at line 938 and tcp_pcb only at line 930, both after the LWIP_END() at line 925.

tcp_sent_handler does not take socket_lock, so on an SMP build an event already queued by tcp_sent_cb/tcp_poll_cb can pass the SocketStateSelectingWrite check at line 1099 in that window and call lwip_tcp_can_send under the lwIP lock at line 1103, reading snd_buf of a pcb that tcp_close may already have freed (for example in CLOSED or SYN_SENT, or on the RST path with unread data).

Please clear tcp_pcb and set the closed state inside the locked block at lines 922-925, as socket_dtor does for the pcb at line 326.

ok | {error, Reason :: term()}.
sendto(Socket, Data, Dest) when is_binary(Data) ->
?MODULE:nif_sendto(Socket, Data, Dest);
sendto_all(Socket, Data, Dest);

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.

On BSD sockets, sendto/3 now waits on nif_select_write when sendto(2) returns EAGAIN (sendto_all, line 799), where before the PR the same condition surfaced as an immediate {error, closed}.

gen_udp_socket:handle_call({sendto, ...}) at gen_udp_socket.erl:178 performs the send inside the gen_server, and send/4 calls it with an infinite timeout, so the server, and with it every other operation on that gen_udp socket, now waits until the socket is writable. OTP's gen_udp_socket sends from the calling process instead, so only the sender blocks there.

Let's handle it: for example a nowait send with the pending datagram kept in the gen_server state, as suggested for gen_tcp_socket. The lwIP UDP path is unaffected, as it never reports would-block.

recv_until_closed(Socket, Acc + byte_size(Data));
{error, closed} ->
Acc;
{error, timeout} ->

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.

{error, timeout} ends the drain quietly with a partial count that no caller checks (they only match is_integer(N)), and the acceptor then closes the server socket. In a healthy run a recv never waits 30 s here, since the client is either still sending or has already closed, so this only fires when the drain has stalled.
If that happens while test_send_backpressure is still in socket:send/2 at line 647, the failure shows up there (a badmatch on {error, closed} or {error, {closed, Rest}}, or a hang) instead of as the stalled drain it is. Since the acceptor is linked, raising here as recv_at_least does at line 901 would keep such a failure diagnosable.

// Another process selecting the same direction is replaced, not queued.
struct SocketSelect *select = socket_get_select(rsrc_obj, direction);
if (select->process_id != ctx->process_id) {
socket_select_disown(env, rsrc_obj, select);

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.

On lwIP, nif_socket_connect parks the process trapped in connect in the write slot, without a monitor (line 3078). A write select from another process while the socket is still connecting reaches this line, disowns that process and takes the slot, and only then raises badarg in the lwIP branch (line 1298): the connecting process is never answered. Please reject a write select on a SocketStateTCPNew socket before this point (patch 0009 does that).

@bettio

bettio commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

petermm added a commit to petermm/AtomVM that referenced this pull request Nov 6, 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>
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