Skip to content

BSD sockets: connect without blocking the scheduler - #2416

Open
gabrielmancini wants to merge 2 commits into
atomvm:release-0.7from
iobee-io:nonblocking-connect
Open

gabrielmancini wants to merge 2 commits into
atomvm:release-0.7from
iobee-io:nonblocking-connect

Conversation

@gabrielmancini

Copy link
Copy Markdown

On platforms with BSD sockets (ESP32, generic_unix) a TCP socket is connected while it is still blocking (otp_socket.c: "TCP sockets are made non-blocking after connect, for now"). A connect to a peer that never answers (an empty address on the LAN, a firewall that drops) holds the scheduler inside the stack until it gives up: minutes on lwIP (TCP_SYNMAXRTX). Nothing else runs, killing the caller does not free it, and on the ESP32 the task watchdog resets the device after 30 s. Any gen_tcp:connect/ssl:connect to such a peer (an MQTT broker behind a dropping firewall, a powered-off Modbus device) does this.

This makes the socket non-blocking before connect. On EINPROGRESS, socket:connect/2 (now an Erlang wrapper over nif_connect/2) waits for the socket to become writable with a new nif_select_write/2, then reads the outcome with SO_ERROR (nif_connect_result/1) as the atoms OTP uses (econnrefused, etimedout, ehostunreach...). Only the calling process waits; killing it abandons the attempt. enif_select_write, declared in erl_nif.h but not implemented, is added. LWIP sockets (rp2) are unchanged: their connect already traps until the callback.

Tested

  • generic_unix: while a connect to 10.255.255.1:80 hangs, another process keeps running (29 ticks of 50 ms in 1.5 s); killing the caller ends it; an open port connects and sends; a closed port returns {error, econnrefused} at once. test-erlang passes; test_estdlib passes except test_gen_tcp's internet connect test, which also fails with the untouched inet backend here (this machine cannot reach www.github.com:80).
  • ESP32 (WROOM, two boards, Wi-Fi and Ethernet): 20 connects in a row to a silent LAN address, no watchdog reset, connections and TLS (MQTT) fine afterwards; a full /24 TCP sweep completes.

No new automated test: a "peer that never answers" depends on the network the CI runs in. Happy to add one if there is a known blackhole address to use.

@bettio

bettio commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

In order to test this, a blackhole address is not needed. A loopback listener whose accept queue is full silently
drops further SYNs, so a connect to it stays in progress until an accept frees a slot.
That gives a deterministic "peer that never answers" with no dependency on the network CI
runs in.

  1. Open a listener on loopback with socket:listen(Socket, 1) and never accept.
  2. Fill the accept queue: from fresh processes, connect until one attempt does not report
    ok within about 500 ms. Do this adaptively rather than with a fixed count, because a
    full listener completes backlog + 1 handshakes on Linux and a few more on the BSDs,
    which matters for the macOS runners. Kill the process whose attempt stayed in progress,
    so that it does not compete for the slot freed in step 5.
  3. Spawn a process that connects to the listener and hands you its socket. After a short
    wait it must still be alive, and the test process must still be running. Then close that
    socket from the test process: the connecting process must get {error, closed}. This
    is the key assertion; with the old code the close hung on the socket lock.
  4. Spawn another connecting process and exit(Pid, kill) it while in progress. It must be
    gone afterwards and nothing else should break.
  5. Spawn a third one, then socket:accept on the listener once. The connect must now return
    ok within a few seconds, when the next SYN retransmission goes through (allow up to
    15 s, the first retransmit is after 1 s).
  6. Clean up: stop the filler processes, close the accepted and listening sockets.

Two smaller additions would round it off: a check that a connect to a closed loopback port
returns {error, econnrefused} (same on both VMs on Linux and macOS), which pins down the
new error mapping; and coverage for enif_select_write in tests/test-enif.c, since it is
now public API. PR #2345 already has such a unit test (pipe write end, immediately
writable, message delivered, then the STOP path), so maybe we should merge that PR first.

Comment thread src/libAtomVM/otp_socket.c Outdated
Comment thread src/libAtomVM/otp_socket.c
@gabrielmancini

Copy link
Copy Markdown
Author

Thanks for the detailed recipe. I added test_connect_in_progress, which follows your steps (adaptive fill, close from the test process → {error, closed}, kill, accept → ok within 15 s; it takes about 2.6 s here), and test_connect_refused. For enif_select_write I added a small test in tests/test-enif.c: pipe write end, message delivered once, then both STOP paths (called and scheduled). If #2345 goes in first, I'll rebase and drop it in favour of theirs.

@gabrielmancini
gabrielmancini requested a review from bettio October 8, 2026 13:35
@gabrielmancini
gabrielmancini force-pushed the nonblocking-connect branch 2 times, most recently from d9f75be to 91d826b Compare October 14, 2026 12:30
@gabrielmancini

Copy link
Copy Markdown
Author

The FreeBSD jobs failed in test_connect_in_progress, the test this PR adds: it fills a listener's accept queue so that the next connect stays in progress, which is what Linux does (it drops further SYNs). The BSDs reset that connect instead, so fill_accept_queue got {error, econnreset} and hit a case clause.

Pushed 91d826b (amended into the test commit): fill_accept_queue now recognizes the reset, and the in-progress checks run only where a connect can stay in progress; on the BSDs the test cleans up and returns. The C code is unchanged. On Linux it still takes the in-progress path (3/3 runs pass locally); the FreeBSD path is for CI to confirm, I have no BSD here.

The esp32 / IDF 5.2.8 failure is test_socket, which connects to hosts on the Internet: the same test failed on #2418 (UART only) and fails here on plain release-0.7, so I read it as unrelated.

@gabrielmancini
gabrielmancini force-pushed the nonblocking-connect branch 2 times, most recently from 6ba34be to 5582729 Compare October 15, 2026 14:12
@gabrielmancini

Copy link
Copy Markdown
Author

@petermm heads-up on the overlap with #2422: both PRs add enif_select_write to resources.c, nearly identically, and yours also splits the select state into read and write, which is the more complete version. The rest doesn't overlap: #2422 covers non-blocking send and the lwIP connect error, and this PR covers the non-blocking connect on BSD sockets (ESP32, generic_unix) plus the OTP error atoms.

I'd suggest #2422 goes first. I'll then rebase this one on top of it, drop my enif_select_write, and build the connect wait on yours. If you'd rather have it the other way round, I'm fine with that too. Just tell me which order you prefer.

A TCP socket was connected while still blocking ("made non-blocking
after connect, for now"), so a connect to a peer that never answers
held the scheduler inside the stack until it gave up -- minutes on lwIP,
long enough for the ESP32 task watchdog to reset the device -- and
killing the caller did not free it.

Now the socket is non-blocking before connect. On EINPROGRESS,
socket:connect/2 waits for it to become writable (nif_select_write, with
enif_select_write, declared but missing until now) and reads the outcome
with SO_ERROR (nif_connect_result), as OTP atoms (econnrefused,
etimedout, ehostunreach...). Only the calling process waits; killing it
abandons the attempt. The NIF is now socket:nif_connect/2; LWIP sockets
are unchanged (they already trap until the connect callback).

Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>
Address review: connect errors go through posix_errno_to_term on both the
synchronous and the asynchronous path (no more {error, closed} when a connect
fails), test_accept_nowait no longer closes with linger 0, and new tests cover
a refused connect, a connect left in progress by a full accept queue (closed
by another process, caller killed, completed after an accept), and
enif_select_write.

Signed-off-by: Gabriel Mancini <gabriel.mancini@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.

2 participants