Environment
- Moddable SDK 8.2.3 (the relevant code in
modules/io/socket/lwip/tcp.c is unchanged on the current public branch)
- ESP-IDF 6.0, ESP32-S3 (M5Stack CoreS3),
CONFIG_LWIP_TCPIP_CORE_LOCKING not set (IDF default)
- Debug build (
mcconfig -d); the device runs an HTTP server (embedded:io listener) serving roughly 0.2–1 request/s from a LAN client, plus occasional outbound HTTP (WAV streaming)
Symptom
Sporadic Guru Meditation Error: Core 1 panic'ed (LoadProhibited) followed by rst:0xc (RTC_SW_CPU_RST). 24 such resets were captured on the serial console over one evening of ordinary traffic. Three panics include full backtraces, all entering through tcpReceive on the lwIP tcpip_thread:
tcpReceive modules/io/socket/lwip/tcp.c:575
tcp_process_refused_data lwip/src/core/tcp.c:1578
tcp_input lwip/src/core/tcp_in.c:434
ip4_input / ethernet_input / tcpip_thread
tcpReceive modules/io/socket/lwip/tcp.c:575
tcp_input lwip/src/core/tcp_in.c:512
ip4_input ...
fxInNetworkDebugLoop xs/platforms/esp/xsPlatform.c:122
tcpReceive modules/io/socket/lwip/tcp.c:559
...
Analysis
tcpReceive runs on the lwIP tcpip_thread. doClose / xs_tcp_destructor run on the XS task and clear the lwIP callbacks with raw tcp_recv/tcp_sent/tcp_err calls and then free the TCP record. (tcp_close itself is marshaled on ESP32 via tcp_close_safe/tcpip_api_call — the callback removal and the free are the unsynchronized steps.) With core locking disabled there is no synchronization between the two threads, so:
- A segment — or a refused-data redelivery, see the
tcp_process_refused_data frame — can enter tcpReceive with arg pointing at a TCP record that the XS thread frees concurrently. The crash sites are the tcp->buffers walk (line 575) and fxInNetworkDebugLoop(tcp->the) (line 559).
- Refused data widens the window considerably:
tcpReceive returns ERR_MEM when c_malloc fails or while fxInNetworkDebugLoop() is true (debug builds), so lwIP parks the pbuf and redelivers it later — for short-lived HTTP connections, frequently after the JS side has already closed the socket.
The existing useCount atomics protect fields of the record but not the lifetime of the arg pointer handed to lwIP callbacks.
Supporting evidence from mitigation
Switching to a release build and lowering the LAN client's polling frequency reduced the crash rate substantially (from ~24/evening) but did not eliminate it — a further rst:0xc occurred after ~17 minutes of release-build uptime, consistent with the direct cross-thread race remaining.
Proposed fix (tested)
The SDK already marshals tcp_close/tcp_write/etc. onto the tcpip thread on ESP32 (modLwipSafe.c's tcpip_api_call idiom) — but callback removal still uses raw tcp_recv/tcp_sent/tcp_err calls from the XS task, immediately before the record is freed. The fix follows the existing idiom:
- Add
tcp_clear_callbacks_safe(pcb) to modLwipSafe.c/h: synchronously (via tcpip_api_call) clear tcp_arg, tcp_recv, tcp_sent, and tcp_err on the tcpip thread. Because tcpip_api_call completes only after the tcpip thread finishes its current message, returning from it guarantees no callback for that pcb is running or can run again — making the subsequent free safe.
- Use it from
removeTCPCallbacks under #if ESP32.
- Add
NULL == arg guards to tcpReceive / tcpSent / tcpError (the cleared tcp_arg makes any residual delivery arrive as NULL; tcpReceive frees the pbuf in that case).
Patch is +44 lines across modules/io/socket/lwip/tcp.c and modules/network/socket/lwip/modLwipSafe.{c,h} (inline below). Running on the affected device now; will report back with soak results. The ECMA-419 UDP glue likely deserves the same treatment for its callback removal, though all captured crashes here were TCP.
Related reports
I could not find an existing issue or PR for this specific crash (searched
tcpReceive, LoadProhibited, use-after-free, and recent commits touching
modules/io/socket/lwip/tcp.c). The closest precedent is the same bug class
in BLE, fixed recently: #1650 "GATTServer crash on late ATT access after
disconnect". This report is the TCP/lwIP analog: a late lwIP callback touching
a record the XS thread has already torn down.
Suggested direction
Marshal callback removal + pcb close + record free onto the tcpip thread (tcpip_callback), or take LOCK_TCPIP_CORE around teardown when core locking is available, or add a lifetime handshake so lwIP callbacks can detect a record that is being torn down.
Happy to provide the full serial capture or test a patch; the crash reproduces within hours on a debug build serving periodic HTTP requests from a LAN poller.
Patch (+44 lines: tcp.c, modLwipSafe.c/h)
diff --git a/modules/io/socket/lwip/tcp.c b/modules/io/socket/lwip/tcp.c
index 0f90023..e1334df 100644
--- a/modules/io/socket/lwip/tcp.c
+++ b/modules/io/socket/lwip/tcp.c
@@ -262,9 +262,18 @@ void xs_tcp_destructor(void *data)
void removeTCPCallbacks(TCP tcp)
{
if (tcp->skt) {
+#if ESP32
+ // Clearing callbacks from the XS task races the lwIP tcpip task,
+ // which may be inside tcpReceive/tcpSent/tcpError with this record
+ // as arg; the caller frees the record right after. Marshal the clear
+ // onto the tcpip task synchronously: when it returns, no callback
+ // for this pcb is running or can run again.
+ tcp_clear_callbacks_safe(tcp->skt);
+#else
tcp_recv(tcp->skt, NULL);
tcp_sent(tcp->skt, NULL);
tcp_err(tcp->skt, NULL);
+#endif
}
tcp->triggerable &= ~kTCPWritable;
@@ -535,6 +544,9 @@ void tcpError(void *arg, err_t err)
{
TCP tcp = arg;
+ if (NULL == tcp) // callbacks cleared during teardown
+ return;
+
removeTCPCallbacks(tcp);
tcp->skt = NULL; // "pcb is already freed when this callback is called"
if (kTCPError & tcp->triggerable)
@@ -546,6 +558,12 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
TCP tcp = arg;
TCPBuffer buffer;
+ if (NULL == tcp) { // callbacks cleared during teardown
+ if (pb)
+ pbuf_free(pb);
+ return ERR_OK;
+ }
+
if ((NULL == pb) || (ERR_OK != err)) { //@@ when is err set here?
removeTCPCallbacks(tcp);
#if ESP32
@@ -590,6 +608,9 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
err_t tcpSent(void *arg, struct tcp_pcb *pcb, u16_t len)
{
+ if (NULL == arg) // callbacks cleared during teardown
+ return ERR_OK;
+
tcpTrigger((TCP)arg, kTCPWritable);
return ERR_OK;
}
diff --git a/modules/network/socket/lwip/modLwipSafe.c b/modules/network/socket/lwip/modLwipSafe.c
index a4cc7fb..dc699dd 100644
--- a/modules/network/socket/lwip/modLwipSafe.c
+++ b/modules/network/socket/lwip/modLwipSafe.c
@@ -169,6 +169,28 @@ void tcp_recved_safe(struct tcp_pcb *tcpPCB, u16_t len)
tcpip_callback_with_block(tcp_recved_INLWIP, msg, 1);
}
+static err_t tcp_clear_callbacks_INLWIP(struct tcpip_api_call_data *tcpMsg)
+{
+ LwipMsg msg = (LwipMsg)tcpMsg;
+ if (msg->tcpPCB) {
+ tcp_arg(msg->tcpPCB, NULL);
+ tcp_recv(msg->tcpPCB, NULL);
+ tcp_sent(msg->tcpPCB, NULL);
+ tcp_err(msg->tcpPCB, NULL);
+ }
+ return ERR_OK;
+}
+
+// Synchronous: when this returns, no lwIP callback for this pcb is running
+// or can run again, so the caller may safely free its callback argument.
+void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB)
+{
+ LwipMsgRecord msg = {
+ .tcpPCB = tcpPCB,
+ };
+ tcpip_api_call(tcp_clear_callbacks_INLWIP, &msg.call);
+}
+
static err_t tcp_listen_INLWIP(struct tcpip_api_call_data *tcpMsg)
{
LwipMsg msg = (LwipMsg)tcpMsg;
diff --git a/modules/network/socket/lwip/modLwipSafe.h b/modules/network/socket/lwip/modLwipSafe.h
index 717808d..0f5c94f 100644
--- a/modules/network/socket/lwip/modLwipSafe.h
+++ b/modules/network/socket/lwip/modLwipSafe.h
@@ -52,6 +52,7 @@
#include "lwip/raw.h"
struct tcp_pcb *tcp_new_safe(void);
+ void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB);
err_t tcp_connect_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected);
u8_t pbuf_free_safe(struct pbuf *p);
err_t tcp_bind_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port);
Edited: corrected the tcp_close_safe description (it is marshaled on ESP32; the unsynchronized step is callback removal), updated the mitigation results (reduced, not eliminated), and added the tested patch.
Environment
modules/io/socket/lwip/tcp.cis unchanged on the currentpublicbranch)CONFIG_LWIP_TCPIP_CORE_LOCKINGnot set (IDF default)mcconfig -d); the device runs an HTTP server (embedded:iolistener) serving roughly 0.2–1 request/s from a LAN client, plus occasional outbound HTTP (WAV streaming)Symptom
Sporadic
Guru Meditation Error: Core 1 panic'ed (LoadProhibited)followed byrst:0xc (RTC_SW_CPU_RST). 24 such resets were captured on the serial console over one evening of ordinary traffic. Three panics include full backtraces, all entering throughtcpReceiveon the lwIPtcpip_thread:Analysis
tcpReceiveruns on the lwIPtcpip_thread.doClose/xs_tcp_destructorrun on the XS task and clear the lwIP callbacks with rawtcp_recv/tcp_sent/tcp_errcalls and then free theTCPrecord. (tcp_closeitself is marshaled on ESP32 viatcp_close_safe/tcpip_api_call— the callback removal and the free are the unsynchronized steps.) With core locking disabled there is no synchronization between the two threads, so:tcp_process_refused_dataframe — can entertcpReceivewithargpointing at aTCPrecord that the XS thread frees concurrently. The crash sites are thetcp->bufferswalk (line 575) andfxInNetworkDebugLoop(tcp->the)(line 559).tcpReceivereturnsERR_MEMwhenc_mallocfails or whilefxInNetworkDebugLoop()is true (debug builds), so lwIP parks the pbuf and redelivers it later — for short-lived HTTP connections, frequently after the JS side has already closed the socket.The existing
useCountatomics protect fields of the record but not the lifetime of theargpointer handed to lwIP callbacks.Supporting evidence from mitigation
Switching to a release build and lowering the LAN client's polling frequency reduced the crash rate substantially (from ~24/evening) but did not eliminate it — a further
rst:0xcoccurred after ~17 minutes of release-build uptime, consistent with the direct cross-thread race remaining.Proposed fix (tested)
The SDK already marshals
tcp_close/tcp_write/etc. onto the tcpip thread on ESP32 (modLwipSafe.c'stcpip_api_callidiom) — but callback removal still uses rawtcp_recv/tcp_sent/tcp_errcalls from the XS task, immediately before the record is freed. The fix follows the existing idiom:tcp_clear_callbacks_safe(pcb)tomodLwipSafe.c/h: synchronously (viatcpip_api_call) cleartcp_arg,tcp_recv,tcp_sent, andtcp_erron the tcpip thread. Becausetcpip_api_callcompletes only after the tcpip thread finishes its current message, returning from it guarantees no callback for that pcb is running or can run again — making the subsequent free safe.removeTCPCallbacksunder#if ESP32.NULL == argguards totcpReceive/tcpSent/tcpError(the clearedtcp_argmakes any residual delivery arrive as NULL;tcpReceivefrees the pbuf in that case).Patch is +44 lines across
modules/io/socket/lwip/tcp.candmodules/network/socket/lwip/modLwipSafe.{c,h}(inline below). Running on the affected device now; will report back with soak results. The ECMA-419 UDP glue likely deserves the same treatment for its callback removal, though all captured crashes here were TCP.Related reports
I could not find an existing issue or PR for this specific crash (searched
tcpReceive,LoadProhibited,use-after-free, and recent commits touchingmodules/io/socket/lwip/tcp.c). The closest precedent is the same bug classin BLE, fixed recently: #1650 "GATTServer crash on late ATT access after
disconnect". This report is the TCP/lwIP analog: a late lwIP callback touching
a record the XS thread has already torn down.
Suggested direction
Marshal callback removal + pcb close + record free onto the tcpip thread (
tcpip_callback), or takeLOCK_TCPIP_COREaround teardown when core locking is available, or add a lifetime handshake so lwIP callbacks can detect a record that is being torn down.Happy to provide the full serial capture or test a patch; the crash reproduces within hours on a debug build serving periodic HTTP requests from a LAN poller.
Patch (+44 lines: tcp.c, modLwipSafe.c/h)
Edited: corrected the
tcp_close_safedescription (it is marshaled on ESP32; the unsynchronized step is callback removal), updated the mitigation results (reduced, not eliminated), and added the tested patch.