Skip to content

TCP: fix pre-accept connection handling and add wolfIP_sock_abort() - #178

Merged
gasbytes merged 6 commits into
wolfSSL:masterfrom
Frauschi:tcp-preaccept-fixes
Sep 29, 2026
Merged

gasbytes merged 6 commits into
wolfSSL:masterfrom
Frauschi:tcp-preaccept-fixes

Conversation

@Frauschi

Copy link
Copy Markdown
Member

Summary

A listener that completes a handshake before the application calls accept() holds the connection on the listening socket itself, and with a handful of static sockets that window is where a server either keeps serving or goes dark. Stress testing a TLS server on a TC4D7 turned up several ways a single misbehaving peer could take a port down, leave the peer hanging, or pin a socket slot indefinitely. This PR fixes those paths in the core and adds an abortive close so an application can drop a peer it has given up on.

  • Connecting sockets report -WOLFIP_EAGAIN, not -1. wolfIP_sock_recvfrom() returned -1 in SYN_SENT and SYN_RCVD, and wolfIP_sock_sendto() did the same in SYN_SENT, so a caller could not tell "wait" from "give up". A non-blocking server hits this on its first read after accepting from SYN_RCVD. LISTEN and the closing states keep -1.
  • The peer is reset when a pre-accept connection is discarded. The pre-accept timeout and accept() with no free socket both reclaimed an established connection silently. A client waiting for the server to speak first never sends another segment, so it never drew the LISTEN RST and hung until its own timeout. Both paths now send an RST.
  • An RST no longer destroys the listening socket. An RST for an un-accepted connection in ESTABLISHED or CLOSE_WAIT called close_socket() on the only socket bound to the port, and the service never answered again. It now reverts to LISTEN, as SYN_RCVD already did. A listener the application has already closed (FIN_WAIT_1 / LAST_ACK) is still torn down.
  • A pre-accept connection in CLOSE_WAIT stays bounded. A peer that connects, writes and shuts down before accept() moves the listener to CLOSE_WAIT, where the pre-accept timer used to disarm itself and leave the port answering every other SYN with an RST. The timer now covers CLOSE_WAIT too.
  • New wolfIP_sock_abort(). This is an abortive close, like SO_LINGER with a zero timeout. It sends an RST in SYN_RCVD, ESTABLISHED, CLOSE_WAIT and FIN_WAIT_1/2 (the set Linux resets; CLOSING and LAST_ACK are released without one, as RFC 9293 specifies) and releases the slot at once. Listeners and closed slots go through wolfIP_sock_close(). The RST carries SND.NXT as actually transmitted, not the send cursor, because a peer silently drops an RST below its RCV.NXT. Aborting a listener that holds an un-accepted connection also reports WOLFIP_FILT_STOP_LISTENING.

API changes

  • New: int wolfIP_sock_abort(struct wolfIP *s, int sockfd);
  • Changed return value: wolfIP_sock_recv() / wolfIP_sock_recvfrom() on a socket in SYN_SENT or SYN_RCVD, and wolfIP_sock_send() / wolfIP_sock_sendto() in SYN_SENT, now return -WOLFIP_EAGAIN instead of -1. A listener keeps -1 in every state, since wolfIP_sock_can_read() reports a listener with a pending connection as readable. Callers that retry on -WOLFIP_EAGAIN now wait for a connection that is still being set up instead of dropping it; code that compared against -1 specifically in these states needs to handle -WOLFIP_EAGAIN.
  • Documented: after wolfIP_sock_close() returns -WOLFIP_EAGAIN, the stack frees the descriptor on its own when the FIN exchange ends, and the number can be handed out again. A repeated close() or abort() on it is only safe until the next socket is created or accepted.

Testing

  • 20 new unit tests in unit_tests_tcp_flow.c, plus updated expectations in the API, socket-arm and DNS/DHCP suites for the EAGAIN change. The abort tests pin the RST sequence number in each state it covers, including after a data retransmission timeout, with a FIN queued behind unacknowledged data, and with a FIN re-queued by the control RTO. Each commit builds and passes make unit on its own (Ubuntu 24.04, libcheck), 1621/1621 at the tip. Each regression test fails against the stack without its fix.
  • On a TC4D7 serving TLS from a two-socket pool:
    • A peer that completed the handshake and then waited was reset in about 5 s instead of hanging for the full 45 s the test allowed.
    • The listener-RST fix came from a plaintext echo server that stopped answering for good after ten connect/write/RST cycles, while a TLS server on another port of the same board kept serving.
    • With the FreeRTOS wrapper's close() falling back to wolfIP_sock_abort() after a bounded wait, it recovered from ten consecutive aborted client handshakes. Before, it refused every connection after the first.
  • Re-run on the TC4D7 demo with this final revision stacked under the FreeRTOS wrapper work: gcc-13 -Werror clean, the network test suite 10/10 (the pre-accept RST case resolves in 4 s), and the stress harness passes with the board still serving at the end.

Docs

  • src/port/wolfssl_io.c: the I/O callback comments now describe the EAGAIN contract; the callbacks' behaviour is unchanged.
  • docs/API.md: wolfIP_sock_abort(), the TCP return-value contract for the send/receive calls, and the descriptor-reuse caveat after a close() that returned -WOLFIP_EAGAIN.

@Frauschi Frauschi self-assigned this Sep 28, 2026
@Frauschi

Copy link
Copy Markdown
Member Author

@wolfSSL-Fenrir-bot review balanced

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot 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.

Fenrir Automated Review — PR #178

Scan targets checked: wolfip-src, wolfip-bugs
Coverage: 1 of 2 in-scope changed file(s) opened by the reviewer; not opened: src/port/wolfssl_io.c

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

Review tier: Balanced

Comment thread src/wolfip.c
@gasbytes gasbytes assigned Frauschi and unassigned wolfSSL-Bot Sep 29, 2026
wolfIP_sock_recvfrom() returned a bare -1 for a socket still in SYN_SENT
or SYN_RCVD, and wolfIP_sock_sendto() did the same for SYN_SENT; that is
the value both use for operations that can never succeed. A caller cannot
tell "wait" from "give up": treat every negative as retryable and a real
failure becomes an endless poll; treat -1 as fatal and a connection that
is merely young dies. A non-blocking TLS server on the native API hits the
second at once, because the first read after accepting from SYN_RCVD
lands before the final ACK.

Return -WOLFIP_EAGAIN in both handshake states, for both directions.
sendto() already does this for SYN_RCVD since the late-accept work; this
makes receive and SYN_SENT consistent with it, and matches what a
non-blocking recv() returns on a connecting socket on Linux. LISTEN and
the closing states keep -1: telling a caller to retry on a socket that is
never coming back would spin for ever. A listener keeps -1 in SYN_RCVD
too: can_read() reports a listener with a pending connection as
readable, so EAGAIN there would send a caller that waits for
readability round a loop that never makes progress.

The wolfSSL I/O callbacks already map EAGAIN to WANT_READ/WANT_WRITE and
-1 to a fatal close, so a handshake started on a connecting socket now
waits instead of failing. Their comments said -1 meant "not
established"; they now say it means a listener or a torn-down stream.

The tests that pinned -1 for SYN_SENT now expect EAGAIN, and assert -1
for LISTEN instead so that path stays covered. docs/API.md gains the
return contract, including that wolfIP_sock_close() returns EAGAIN while
the FIN exchange is outstanding. The stack frees that descriptor by itself
once the exchange ends, and a descriptor is only a slot index, so the doc
also says that a repeated close is safe only until the next socket is
created or accepted: after that it would close the new socket.
A connection whose handshake completed but which the application did not
accept within TCP_PREACCEPT_TIMEOUT_MS was reclaimed silently. The peer
had finished its own handshake and considered itself connected, so it
waited on a connection this stack had already thrown away - no FIN, no
RST - until its own timeout fired, if it had one.

The expiry path assumed "the peer's next segment gets the normal LISTEN
RST". That only holds for a peer with something left to send. A client
waiting for the server to speak first has nothing: a TLS client that has
sent its ClientHello and waits for the server's flight never sends another
segment, so it never draws that RST. Measured on hardware, the peer hung
for the full 45 s a test allowed, against a server that had dropped it
40 s earlier.

tcp_send_reset_now() sends an RST|ACK straight through
tcp_send_empty_immediate(). It cannot go through tcp_send_empty(): that
queues into the socket's TX FIFO, and the listener revert that follows
reinitialises the FIFO, so the segment would never leave. The segment is
built by tcp_build_empty(), split out of tcp_send_empty() so both share
it.

Called from the two paths that discard an established pre-accept
connection: the pre-accept expiry in tcp_rto_cb(), and accept() when no
socket is free for the hand-off. The SYN_RCVD control-RTO revert is left
alone; there the peer retransmits its SYN and draws the LISTEN RST.

The connection is still lost. What changes is that the peer is told, and
fails in about 5 s instead of hanging.
A listener that completes a handshake before the application accepts is
carrying a connection while remaining the only socket bound to the port.
If an RST arrived in that window, the generic reset path called
close_socket() on it, and the port never answered again: one peer that
connected, sent, and reset instead of closing took the service down for
good. On hardware it did not come back within four minutes, and every
later connection was refused.

The SYN_RCVD case above already reverts a listener to LISTEN for this
reason. This is the same socket one state later, so it reverts too.

Only ESTABLISHED and CLOSE_WAIT revert, the states accept() can still hand
off. A listener the application closed before accepting sits in
FIN_WAIT_1 or LAST_ACK with is_listener still set; it has given the port
up, and reverting it would leave a listener with no callback answering
SYNs on a port the application may already have bound again. Those states
keep the close_socket() teardown.

Found by stress testing: a plaintext echo server died after ten
connections that wrote and then reset, while a TLS server on another port
of the same board kept serving. Only the echo server had a connection
sitting un-accepted when the reset landed.
The pre-accept timer bounds how long a listener that completed a
handshake can hold the port without the application accepting. It
disarmed itself on any state other than ESTABLISHED, reading anything
else as "accepted away, reset, or closed". CLOSE_WAIT is none of those: a
peer that connects and closes without waiting - anything that writes a
request and shuts down its write side - takes an un-accepted listener
straight from ESTABLISHED to CLOSE_WAIT. The timer then disarmed, and
until the application called accept() the port answered every other SYN
with an RST.

accept() now hands a CLOSE_WAIT listener off, so a server that gets round
to it recovers. One that does not - busy, stuck, or not polling that
descriptor - held the port indefinitely, while the same connection one
FIN earlier would have been reclaimed after TCP_PREACCEPT_TIMEOUT_MS.
Treat CLOSE_WAIT as still pinned, so the timer fires and reverts the
listener as it does for ESTABLISHED.
wolfIP_sock_close() on a connected socket starts the FIN exchange and
keeps the slot until the peer finishes it. A peer that stops reading, or
aborts mid-handshake and never answers the FIN, can hold that slot for as
long as the control retransmissions last - and on a stack with a handful
of static sockets, one or two such peers is the difference between
serving and refusing every new connection. A server that has decided a
peer is dead has no way to say "drop it now".

wolfIP_sock_abort() is that: SO_LINGER with a zero timeout. It sends an
RST when the peer holds a synchronized connection (SYN_RCVD, ESTABLISHED,
CLOSE_WAIT, FIN_WAIT_1/2, the same set Linux resets on abort) and
releases the slot at once. A listener and an already-closed slot go
through wolfIP_sock_close(), which releases those without a peer to tell.
It is also valid after wolfIP_sock_close() returned -WOLFIP_EAGAIN, which
is how a caller bounds a graceful close, as long as no socket has been
created or accepted since: the stack frees a closing slot by itself, and
its number can be handed out again.

The RST carries SND.NXT as transmitted, not the seq cursor. seq runs
ahead by queued, unsent data and is never advanced past the FIN, and a
peer silently drops an RST below its RCV.NXT: aborting a socket stuck in
FIN_WAIT_2 would free it here and leave the peer connected.
tcp_snd_nxt() takes the end of the last transmitted segment from the TX
FIFO, adds the FIN once it has gone out, and uses ISS+1 in SYN_RCVD. It
errs high rather than low, because a peer answers an in-window RST at the
wrong sequence with a challenge ACK, and the freed port answers that with
an RST at the peer's own ACK number.

Aborting a listener that still holds an un-accepted connection gives up
the port, so it reports WOLFIP_FILT_STOP_LISTENING as well as
WOLFIP_FILT_CLOSED.

On a TC4D7 serving TLS from a two-socket pool, with the FreeRTOS
wrapper's close() falling back to this after a bounded wait: ten
consecutive aborted client handshakes, ten recoveries, against a build
that refused every connection after the first.
@danielinux
danielinux removed their request for review September 29, 2026 10:33
wolfIP_sock_sendto() still returned -WOLFIP_EAGAIN for a listener in
SYN_RCVD, while wolfIP_sock_recvfrom() already returned -1 there. Sending
on a listener can never succeed, so a caller told to retry would loop for
ever.

The gap was wider than the handshake states. A connection that completes
before accept() lives on the listener slot in ESTABLISHED or CLOSE_WAIT,
and there sendto() queued data and advanced the sequence cursor, and
recvfrom() consumed the pending connection's early data. accept() then
copies that cursor into the child but discards the TX FIFO, so the
accepted stream would run ahead of anything it could retransmit, and
bytes read through the listener were lost to the accepted socket.

A listener descriptor is never a data socket: is_listener is only ever
cleared on the child accept() creates. sendto() and recvfrom() now return
-1 for a listener in every state, and wolfIP_sock_can_write() reports the
fixed readiness of 1 for it, as it already did in LISTEN, so a caller
waiting for writability reaches the error instead of blocking.
@Frauschi
Frauschi requested a review from gasbytes September 29, 2026 11:03
@Frauschi Frauschi assigned gasbytes and unassigned Frauschi Sep 29, 2026
@gasbytes
gasbytes merged commit 2794a0a into wolfSSL:master Sep 29, 2026
47 checks passed
@Frauschi
Frauschi deleted the tcp-preaccept-fixes branch September 29, 2026 11:41
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