Skip to content

Reduce undefined behavior - #124

Open
willmmiles wants to merge 18 commits into
ESP32Async:mainfrom
willmmiles:core-changes
Open

willmmiles wants to merge 18 commits into
ESP32Async:mainfrom
willmmiles:core-changes

Conversation

@willmmiles

Copy link
Copy Markdown

Apologies in advance for the monster PR. The development work from #123 identified a substantial number of use after frees, resource leaks, and other unspecified behavior issues. This PR checkpoints core changes to resolve most of the issues discovered so far.

Fixed issues broken down by category:

Opening a connection

No way to cancel an outstanding connection attempt (DNS or otherwise)

client->connect("slow.example", 8080);
client->close();     // or abort()

Naiively, one might expect that this would cancel the pending connection. Instead the close() is ignored, and the connection will proceed.

A failed connect() leaks the pcb and its local port

if (!client->connect(host, 8080)) {
   // pcb was leaked!
}

As described - connect() returns false with no cleanup. One pcb and one local port are lost per failed dial, which is the common case when WiFi drops and the application retries.

setNoDelay() before the handshake completes is a silent no-op

client->connect(host, 8080);
client->setNoDelay(true);   // ignored; getNoDelay() returns false

Both accessors early-return while the pcb is still unattached. (This follows from the late pcb adoption noted at the end rather than being an independent bug, but the effect on the application is real.)

Connecting to a host by name

A late DNS answer dereferences a destroyed client

client->connect("slow.example", 443);
delete client;   // or it goes out of scope

lwIP cannot cancel an outstanding lookup, and the callback argument was the raw AsyncClient *. When the answer arrives, the library reads the destroyed object and attempts to connect, resulting in a use-after-free.

A failed lookup connects to 0.0.0.0 instead of reporting the failure

A name that does not resolve produces no onError/onDisconnect at all. The library zeroes the address when the resolver reports failure and then takes the success path, so it allocates a pcb, dials 0.0.0.0, and leaks the pcb. Also possible on the immediate-connect path if NXDOMAIN was cached.

A stale lookup answer connects to the host you moved on from

Lookups carry no identity, so a superseded one cannot recognise itself. Calling connect() a second time while the first lookup is outstanding leaves you connected to the first host (if the stale answer arrives first), or with the second connect() silently producing no connection at all. Both a close()-then-reconnect and a straight second connect() hit this.

Handling errors and reconnecting

Reconnecting from inside onError misreports the connection

client->onError([](void *, AsyncClient *c, int8_t) { c->connect(host, port); });

onDisconnect fires immediately afterwards, so the application is told the replacement connection dropped. Reconnecting by name behaves differently again: the reset arrives as onDisconnect rather than onError, and the reconnect attempted from the handler never happens.

connect() succeeds while an error event is still undelivered

A reset that has been observed but not yet dispatched leaves the client looking idle, so a fresh connect() is accepted — and the pending reset is then reported against the connection that replaced it. A client that still owes a notification should refuse to connect.

Destroying or closing a client

close() inside onData drops the acknowledgement

client->onData([](void *, AsyncClient *c, void *, size_t) { /* handle */ c->close(); });

The ack is emitted only if a pcb is still attached, and close() has already detached it. The peer is sent a TCP RST for data that was in fact delivered.

This was masked by the _rx_ack_len member being uninitialized, so we would often tcp_recved an absurd number of bytes before processing the close, resulting in some connections apparently closing correctly.

A withheld ack carries over into the next connection

After ackLater() on one connection, closing and reconnecting charges the old debt against
the new pcb: the first ack() on the new connection acknowledges bytes that arrived on the
previous one. Per-connection state is never reset when a pcb is adopted.

Deleting the client inside onData crashes

client->onDisconnect([](void *, AsyncClient *c) { delete c; });
client->onData([](void *, AsyncClient *c, void *, size_t) { if (transaction_complete) c->close(); });

The receive loop continues after the handler returns and reads the freed object. This is most likely to happen as a result of client->close() being called in the handler when the transaction completes, resulting in the onDisconnect() handler deleting the client object.

Deleting the client inside onError crashes

client->onError([](void *, AsyncClient *c, int8_t) { delete c; });

The error path runs onDisconnect unconditionally against the same object straight afterwards. Results in a use-after-free.

Destroying a client after a reset crashes and swallows onDisconnect

If the peer resets and the application destroys the client before the event is dispatched, the destructor does nothing — it only cleans up when a pcb is still attached, and the reset already cleared it. The queued event later dispatches against freed memory, and the owed onDisconnect is never delivered. Use-after-free plus a missing callback.

AsyncServer

Destroying an AsyncServer with an accept in flight crashes

A queued accept holds a raw pointer to the server, so a connection accepted but not yet delivered dereferences the server after it has gone out of scope, resulting in a use-after-free.

A failed listen() leaks the bound pcb and holds the port

Bind and listen are not unwound as one transaction: when the listen pcb cannot be allocated, the bound pcb is never freed and the port is held for the life of the process, so no later begin() on that port can ever succeed.

A connection reset before delivery leaks the client object

If the peer resets between accept and delivery, the queued event is purged but the AsyncClient it owned is not. 360 bytes leaked per occurrence.

end() still delivers connections from the listener it closed

server.end();   // a connection was accepted but not yet delivered

onClient is invoked anyway, handing the application a connection on a listener it has already shut down. The pcb and client are leaked.

Restarting a server resurrects an accept the previous session disowned

end() followed by begin() makes the stale queued accept deliverable again, so a connection belonging to the closed listening session is handed to the new one. Same leak.

Other fixes and improvements

AsyncTCPSimpleIntrusiveList uninitialized member

The list element count was not initialized. This only "worked" because the one use case (the main queue) was instantiated in zero'd memory at program startup.

Fully compile out callback timing if not being collected

Unfortunately it turns out the optimizer can't resolve micros(), so it was building in these calls even when the logs themselves were eliminated. Update the build guard to avoid the unnecessary work unless the logs are being generated.

Measure available write() size atomicly

Ensure we can write the most bytes by doing the check in the LwIP lock scope instead of before.

Improve core locking technique

Add a metafunction allowing code blocks requiring LwIP core mutexing to be inlined in to their calling functions, keeping the relevant code together. This is also slightly faster when mutex-based core locking is available or unnecessary (arduino-3, ESP8266 or other non-RTOS platforms) and falls back cleanly to tcpip_call_api when not.


Alongside this effort, Claude and I began developing a formal test suite; I will PR that separately as it's still in a bit of a nascent form (and still turning up more issues).

willmmiles and others added 18 commits September 14, 2026 00:48
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LwIP frees the original only on success, so the port stayed reserved for the rest of the
boot and every later begin() on it failed with ERR_USE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ERR_OK after tcp_abort() sends LwIP into tcp_receive() on a freed pcb; any error but
ERR_ABRT makes it abort a second time.  Allocating the event before the AsyncClient also
keeps a client off the failure path, which we could not destroy from this thread anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
begin() entered the LwIP context five times over, so a pcb could be seen bound but not
listening, and _pcb reached callers in stages.  end() was the same in reverse, letting an
accept queue its event after the sweep - _remove_events_for_client() keys on the client,
so ~AsyncServer() then called _accepted() on freed memory.  end() disposes of the detached
chain outside its transaction, because that closes pcbs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_adopt() is the single point where a pcb is taken on.  connect() adopts immediately rather
than at _connected(): the pcb used to sit unreferenced until the connect completed, so
close(), abort() and the destructor could not find it.

 - Fold _close() into close(); the ackLater() flush now covers the RX timeout and
   destructor paths.
 - Dispose of the pcb when tcp_connect() fails; LwIP neither frees nor registers it.
 - Skip poll processing while connecting, now that polls can reach an unestablished client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The event carries the address inline, so &e->dns.addr was never null and the failure
branch was dead.  An any-address answer is treated as failure too, on both routes - a
cached name resolves synchronously and never reaches the callback.

 - Report ERR_CONN when the inner connect() fails; an attempt that returned true now ends
   in exactly one callback.
 - Drop dns.name: it points into LwIP's DNS table and dangles by dispatch.  Never read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three parties held AsyncClient as a raw pointer with no shared ownership, and two holes
could not be closed: the async task drops the queue mutex before invoking a callback, and
deleting a client inside onData destroyed the std::function that was running.  State and
handlers move to AsyncClientImpl, shared by the facade and every queued event.  LwIP's
pointers are deliberately not owners - ~AsyncClient() closes unconditionally and the
destructor only asserts the binding is clear, since it may run on any thread.

 - _facade is nulled under the queue mutex; dispatch is gated on it, so a detached
   implementation just drains.
 - The queue mutex is created by AsyncClient and AsyncServer construction, ahead of every
   user.
 - The facade's forwarders are always_inline: these builds have no LTO.
 - close()'s ack folds into the close transaction, so the peer is no longer RST over data
   we had in fact processed.
 - _error() and close() hold a reference across their callbacks and re-read _facade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LwIP cannot cancel a lookup, and promises exactly one callback iff dns_gethostbyname()
returns ERR_INPROGRESS.  The callback argument is a heap-allocated weak_ptr owned by LwIP,
transferred on ERR_INPROGRESS and nowhere else; weak, so a client destroyed mid-lookup is
not pinned until the resolver times out.  It doubles as the lookup's identity, so
superseded lookups orphan themselves - a bare flag cannot tell connect(A), close(),
connect(B) apart.

 - Token and query are published in one transaction, or the callback can arrive first and
   drop an answer connect() has reported success for.
 - The answer is dialled from the callback; only the failure path defers, because it runs
   user callbacks.
 - close() and abort() stand a lookup down inside their own transactions, so the token is
   written only in LwIP context and needs no lock.
 - A new target supersedes rather than being refused: refusing is the better API, but
   would strand existing callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A callback that reconnects leaves _pcb non-null, so the loop kept feeding the old
connection's segments to onData against the new pcb.  The in-callback ack has the same
flaw, and tcp_recved() on a pcb in SYN_SENT draws an RST.  Compare against the pcb the
data arrived on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only _dispose_event_chain() knew to destroy an undelivered AsyncClient, so a peer that
reset between accept and dispatch had its event purged and its client leaked.  It cannot
be destroyed where the event is freed either - every caller of _remove_events_for_client()
runs on the LwIP thread.  So tcp_accept() creates only the implementation, and the async
task wraps a facade round it on delivery.

 - end() marks queued accepts instead of handing a chain back out of the transaction.
 - tcp_error() clears _pcb before the purge so it can drop the last reference, and stops
   early on a null _facade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ASYNCTCP_ASSERT is off by default, against ESP-IDF's usual, and enabled by
CONFIG_ASYNC_TCP_ASSERTIONS.
An accept event outlives its AsyncServer, and marking queued events cannot reach one the
async task has already dequeued; waiting for that task deadlocks, since end() may be
called from onClient.  State moves to AsyncServerImpl, shared with the accept event, and
LwIP is handed the implementation.

 - _facade plus a listening epoch stamped on each accept subsume the marking pass;
   _orphan_events_for_server() is gone.
 - The epoch, not _pcb, is what survives end() followed by begin(): _pcb is current state,
   not history.
 - AsyncServer is noncopyable and nonmovable now - the implementation holds one
   back-pointer to its facade.
 - Removes AsyncServer::_accept(), declared but never defined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With core locking, inline the lambda under the lock; without it, delegate to
tcpip_api_call with a functor on the stack.  Path selection moves to LwIP's defines rather
than the IDF's, tested by value: opt.h defines NO_SYS and LWIP_TCPIP_CORE_LOCKING
unconditionally, so an #ifdef would send every build down the lock-free path.  Neither
route may be entered from the LwIP thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
micros() is opaque, so the compiler cannot drop the pair around each user callback when
the log itself compiles out - twenty-two relocations against it in a build with logging
off.  Gate the timing on the verbose level; CONFIG_ASYNC_TCP_LOG_ELAPSED forces it either
way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
space() was read on the calling task and the figure handed to the write transaction, so a
window that shrank in between drew ERR_MEM and lost the whole send.  The unlocked space()
at the top stays as an advisory early-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whether onDisconnect arrived turned on the close transaction's result code, where ERR_OK
meant both "a live pcb was closed" and "a queued terminal event was purged unseen".
_dispose_owed tracks the obligation instead: armed wherever the application is promised a
terminal notification, claimed by whichever path ends the connection.  Fixes abort()
during a lookup reporting nothing, a reset client destroyed before dispatch draining
silently, and ~AsyncClient() reporting twice.

 - Both arming points sit in LwIP context; arming on the caller's side races the answer
   and can wedge the client against every later connect().
 - connect() refuses while a notification is owed, and the hostname overload tests the
   token too, since superseding a lookup transfers the obligation.
 - _error() claims the notification before running onError, then suppresses onDisconnect
   if the callback started a new attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@willmmiles

willmmiles commented Sep 19, 2026 •

Copy link
Copy Markdown
Author

LibreTiny CI issues seem to a result of an incompatibility with PlatformIO 6.2.0.

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.

1 participant