ESPHome 2026.3.0-dev
Loading...
Searching...
No Matches
lwip_fast_select.c
Go to the documentation of this file.
1// Fast socket monitoring for ESP32 and LibreTiny (LwIP >= 2.1.3)
2// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
3//
4// This must be a .c file (not .cpp) because:
5// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units
6// 2. The netconn callback is a C function pointer
7//
8// USE_ESP32 and USE_LIBRETINY platform flags (-D) control compilation of this file.
9// See the guard at the bottom of the header comment for details.
10//
11// Thread safety analysis
12// ======================
13// Three threads interact with this code:
14// 1. Main loop task — calls init, has_data, hook
15// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent
16// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex)
17// 3. Background tasks — call wake_main_loop
18//
19// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524):
20// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c
21// - event_callback (static, same for all sockets): L327
22// - DEFAULT_SOCKET_EVENTCB = event_callback: L328
23// - tryget_socket_unconn_nouse (direct array lookup): L450
24// - lwip_socket_dbg_get_socket (thin wrapper): L461
25// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759
26// - event_callback definition: L2538
27// - SYS_ARCH_PROTECT before rcvevent switch: L2578
28// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582
29// - SYS_ARCH_UNPROTECT after switch: L2615
30// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h
31// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495
32// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506
33// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock)
34//
35// Socket slot lifetime
36// ====================
37// This code reads struct lwip_sock fields without SYS_ARCH_PROTECT. The safety
38// argument requires that the slot cannot be freed while we read it.
39//
40// In LwIP, the socket table is a static array and slots are only freed via:
41// lwip_close() -> lwip_close_internal() -> free_socket_free_elements() -> free_socket()
42// The TCP/IP thread does NOT call free_socket(). On link loss, RST, or timeout
43// it frees the TCP PCB and signals the netconn (rcvevent++ to indicate EOF), but
44// the netconn and lwip_sock slot remain allocated until the application calls
45// lwip_close(). ESPHome removes the fd from the monitored set before calling
46// lwip_close().
47//
48// Therefore lwip_socket_dbg_get_socket(fd) plus a volatile read of rcvevent
49// (to prevent compiler reordering or caching) is safe as long as the application
50// is single-writer for close. ESPHome guarantees this by design: all socket
51// create/read/close happens on the main loop. fd numbers are not reused while
52// the slot remains allocated, and the slot remains allocated until lwip_close().
53// Any change in LwIP that allows free_socket() to be called outside lwip_close()
54// would invalidate this assumption.
55//
56// LwIP source references for slot lifetime:
57// sockets.c (same commit as above):
58// - alloc_socket (slot allocation): L419
59// - free_socket (slot deallocation): L384
60// - free_socket_free_elements (called from lwip_close_internal): L393
61// - lwip_close_internal (only caller of free_socket_free_elements): L2355
62// - lwip_close (only caller of lwip_close_internal): L2450
63//
64// Shared state and safety rationale:
65//
66// s_main_loop_task (TaskHandle_t, 4 bytes):
67// Written once by main loop in init(). Read by TCP/IP thread (in callback)
68// and background tasks (in wake).
69// Safe: write-once-then-read pattern. Socket hooks may run before init(),
70// but the NULL check on s_main_loop_task in the callback provides correct
71// degraded behavior — notifications are simply skipped until init() completes.
72//
73// s_original_callback (netconn_callback, 4-byte function pointer):
74// Written by main loop in hook_socket() (only when NULL — set once).
75// Read by TCP/IP thread in esphome_socket_event_callback().
76// Safe: set-once pattern. The first hook_socket() captures the original callback.
77// All subsequent hooks see it already set and skip the write. The TCP/IP thread
78// only reads this after the callback pointer has been swapped (which happens after
79// the write), so it always sees the initialized value.
80//
81// sock->conn->callback (netconn_callback, 4-byte function pointer):
82// Written by main loop in hook_socket(). Never restored — all LwIP sockets share
83// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently.
84// Read by TCP/IP thread when invoking the callback.
85// Safe: 32-bit aligned pointer writes are atomic on Xtensa, RISC-V (ESP32),
86// and ARM Cortex-M (LibreTiny). The TCP/IP thread will see either the old or
87// new pointer atomically — never a torn value. Both the wrapper and original
88// callbacks are valid at all times (the wrapper itself calls the original),
89// so either value is correct.
90//
91// sock->rcvevent (s16_t, 2 bytes):
92// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT.
93// Read by main loop in has_data() via volatile cast.
94// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex (ESP32) or resumes the
95// scheduler (LibreTiny), both providing a memory barrier. The volatile cast
96// prevents the compiler from caching the read. Aligned 16-bit reads are
97// single-instruction loads on Xtensa (L16SI), RISC-V (LH), and ARM Cortex-M
98// (LDRH), which cannot produce torn values. On single-core chips (LibreTiny,
99// ESP32-C3/C6/H2) cross-core visibility is not an issue.
100//
101// FreeRTOS task notification value:
102// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks
103// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake).
104// Safe: FreeRTOS notification APIs are thread-safe by design (use internal
105// critical sections). Multiple concurrent xTaskNotifyGive calls are safe —
106// the notification count simply increments.
107
108// USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is
109// visible in both .c and .cpp translation units.
110#ifdef USE_LWIP_FAST_SELECT
111
112// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc.
113#include <lwip/api.h>
114#include <lwip/priv/sockets_priv.h>
115// FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not
116#ifdef USE_ESP32
117#include <freertos/FreeRTOS.h>
118#include <freertos/task.h>
119#else
120#include <FreeRTOS.h>
121#include <task.h>
122#endif
123
125
126#include <stddef.h>
127
128// IRAM_ATTR is defined by esp_attr.h (included via FreeRTOS headers) on ESP32.
129// On LibreTiny it's not defined — provide a no-op fallback.
130#ifndef IRAM_ATTR
131#define IRAM_ATTR
132#endif
133
134// Compile-time verification of thread safety assumptions.
135// On ESP32 (Xtensa/RISC-V) and LibreTiny (ARM Cortex-M), naturally-aligned
136// reads/writes up to 32 bits are atomic.
137// These asserts ensure our cross-thread shared state meets those requirements.
138
139// Pointer types must fit in a single 32-bit store (atomic write)
140_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access");
141_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access");
142
143// rcvevent must fit in a single atomic read
144_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access");
145
146// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V/ARM.
147// Misaligned access would not be atomic even if the size is <= 4 bytes.
148_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0,
149 "netconn.callback must be naturally aligned for atomic access");
150_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0,
151 "lwip_sock.rcvevent must be naturally aligned for atomic access");
152
153// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks.
154static TaskHandle_t s_main_loop_task = NULL;
155
156// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task.
157static netconn_callback s_original_callback = NULL;
158
159// Wrapper callback: calls original event_callback + notifies main loop task.
160// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR).
161static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) {
162 // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent,
163 // signals any select() waiters. This preserves all LwIP behavior.
164 // s_original_callback is always valid here: hook_socket() sets it before swapping
165 // the callback pointer, so this wrapper cannot run until it's initialized.
166 s_original_callback(conn, evt, len);
167 // Wake the main loop task if sleeping in ulTaskNotifyTake().
168 // Only notify on receive events to avoid spurious wakeups from send-ready events.
169 // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS
170 // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
171 // already wake the main loop through the RCVPLUS path.
172 if (evt == NETCONN_EVT_RCVPLUS) {
173 TaskHandle_t task = s_main_loop_task;
174 if (task != NULL) {
175 xTaskNotifyGive(task);
176 }
177 }
178}
179
180void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); }
181
182// lwip_socket_dbg_get_socket() is a thin wrapper around the static
183// tryget_socket_unconn_nouse() — a direct array lookup without the refcount
184// that get_socket()/done_socket() uses. This is safe because:
185// 1. The only path to free_socket() is lwip_close(), called exclusively from the main loop
186// 2. The TCP/IP thread never frees socket slots (see "Socket slot lifetime" above)
187// 3. Both has_data() reads and lwip_close() run on the main loop — no concurrent free
188// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select().
189// Returns the sock only if both the sock and its netconn are valid, NULL otherwise.
190static inline struct lwip_sock *get_sock(int fd) {
191 struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
192 if (sock == NULL || sock->conn == NULL)
193 return NULL;
194 return sock;
195}
196
198 struct lwip_sock *sock = get_sock(fd);
199 if (sock == NULL)
200 return false;
201 // volatile prevents the compiler from caching/reordering this cross-thread read.
202 // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a
203 // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value
204 // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on
205 // Xtensa/RISC-V/ARM and cannot produce torn values.
206 return *(volatile s16_t *) &sock->rcvevent > 0;
207}
208
210 struct lwip_sock *sock = get_sock(fd);
211 if (sock == NULL)
212 return;
213
214 // Save original callback once — all LwIP sockets share the same static event_callback
215 // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM).
216 if (s_original_callback == NULL) {
217 s_original_callback = sock->conn->callback;
218 }
219
220 // Replace with our wrapper. Atomic on all supported platforms (32-bit aligned pointer write).
221 // TCP/IP thread sees either old or new pointer — both are valid.
222 sock->conn->callback = esphome_socket_event_callback;
223}
224
225// Wake the main loop from another FreeRTOS task. NOT ISR-safe.
227 TaskHandle_t task = s_main_loop_task;
228 if (task != NULL) {
229 xTaskNotifyGive(task);
230 }
231}
232
233// Wake the main loop from an ISR. ISR-safe variant.
234void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken) {
235 TaskHandle_t task = s_main_loop_task;
236 if (task != NULL) {
237 vTaskNotifyGiveFromISR(task, (BaseType_t *) px_higher_priority_task_woken);
238 }
239}
240
241// Wake the main loop from any context (ISR, thread, or main loop).
242// ESP32-only: uses xPortInIsrContext() to detect ISR context.
243// LibreTiny is excluded because it lacks IRAM_ATTR support needed for ISR-safe paths.
244#ifdef USE_ESP32
246 if (xPortInIsrContext()) {
247 int px_higher_priority_task_woken = 0;
248 esphome_lwip_wake_main_loop_from_isr(&px_higher_priority_task_woken);
249 portYIELD_FROM_ISR(px_higher_priority_task_woken);
250 } else {
252 }
253}
254#endif
255
256#endif // USE_LWIP_FAST_SELECT
void IRAM_ATTR esphome_lwip_wake_main_loop_any_context(void)
Wake the main loop task from any context (ISR, thread, or main loop).
void esphome_lwip_fast_select_init(void)
Initialize fast select — must be called from the main loop task during setup().
bool esphome_lwip_socket_has_data(int fd)
Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket).
void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken)
Wake the main loop task from an ISR — costs <1 us.
void esphome_lwip_hook_socket(int fd)
Hook a socket's netconn callback to notify the main loop task on receive events.
void esphome_lwip_wake_main_loop(void)
Wake the main loop task from another FreeRTOS task — costs <1 us.
uint32_t len