Reduce undefined behavior - #124
Open
willmmiles wants to merge 18 commits into
Open
willmmiles wants to merge 18 commits into
willmmiles wants to merge 18 commits into
Conversation
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>
Author
|
LibreTiny CI issues seem to a result of an incompatibility with PlatformIO 6.2.0. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
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 portAs described -
connect()returnsfalsewith 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-opBoth 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
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/onDisconnectat all. The library zeroes the address when the resolver reports failure and then takes the success path, so it allocates a pcb, dials0.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 secondconnect()silently producing no connection at all. Both aclose()-then-reconnect and a straight secondconnect()hit this.Handling errors and reconnecting
Reconnecting from inside
onErrormisreports the connectiononDisconnectfires immediately afterwards, so the application is told the replacement connection dropped. Reconnecting by name behaves differently again: the reset arrives asonDisconnectrather thanonError, and the reconnect attempted from the handler never happens.connect()succeeds while an error event is still undeliveredA 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()insideonDatadrops the acknowledgementThe 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_lenmember being uninitialized, so we would oftentcp_recvedan 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 againstthe new pcb: the first
ack()on the new connection acknowledges bytes that arrived on theprevious one. Per-connection state is never reset when a pcb is adopted.
Deleting the client inside
onDatacrashesThe 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 theonDisconnect()handler deleting the client object.Deleting the client inside
onErrorcrashesThe error path runs
onDisconnectunconditionally against the same object straight afterwards. Results in a use-after-free.Destroying a client after a reset crashes and swallows
onDisconnectIf 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
onDisconnectis never delivered. Use-after-free plus a missing callback.AsyncServerDestroying an
AsyncServerwith an accept in flight crashesA 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 portBind 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
AsyncClientit owned is not. 360 bytes leaked per occurrence.end()still delivers connections from the listener it closedserver.end(); // a connection was accepted but not yet deliveredonClientis 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 bybegin()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 atomiclyEnsure 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 totcpip_call_apiwhen 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).