feat(socket): Add SocketReactor (select() loop + thread pool) and expose it - #688
Conversation
…ose it Replace the one-thread-per-receiver model with espp::SocketReactor: a single select() event-loop thread that multiplexes many receiver sockets and dispatches each socket's read + user callback onto a shared espp::ThreadPool. This collapses "N receiver threads" into "1 loop thread + a small pool" that can be shared across subsystems (dns_server, rtps, rtsp, ftp, ...). Design: - One-shot arm/disarm: a readable socket is disarmed and a pool job is submitted, then re-armed only after that job completes, so there is at most one in-flight handler per socket (per-socket ordering, no concurrent recv on one fd) while different sockets run concurrently. Pool-full reverts and lets select() re-report (natural backpressure, no loss). - A loopback UDP "wakeup" socket in the select set makes add/remove/re-arm/stop immediately responsive. - The thread pool may be owned (built from Config) or an external shared_ptr. - UDP path: add_udp_receiver(). TCP paths: add_tcp_listener() (accept -> dispatch) and add_tcp_stream() (recv -> callback, auto-remove on EOF/disconnect). Plus a low-level add_fd()/remove(). - Registration is guarded against FD_SETSIZE (fd_set is a value-indexed bitmap on POSIX/lwip; skipped on Winsock where the limit is count, not value). Supporting changes: UdpSocket::bind(ReceiveConfig) is factored out of start_receiving() (so a socket can be bound for external-loop use) and Socket gains native_handle(). TcpSocket::connected_ and Socket::Info's address formatting are made thread-safe (atomic + inet_ntop) for concurrent reactor use. Docs/tests: - ESP example (components/socket/example) gains reactor + TCP-reactor scenarios and a shared-pool / lifecycle / multi-client / UDP-overloads batch (15 pass/fail scenarios total) with an explicit failure summary. - Host libraries (lib): socket_reactor.cpp is built into the C++ static lib and the Python extension; socket_reactor.hpp is added to the espp.hpp aggregate. litgen cannot parse the header, so Python bindings are hand-written in socket_reactor_bindings.cpp (GIL-safe UDP-receiver callback), registered via py_init_socket_reactor; the autogenerate script documents the exclusion. - Host examples/tests: python/socket_reactor.py, python/socket_reactor_test.py (17/17), and pc/tests/socket_reactor.cpp (23/23, incl. the C++-only TCP paths). Verified: ESP example builds; the standalone lib builds (C++ + _espp module); and the python/C++ host tests pass against the locally-built module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
Pull request overview
Adds a new espp::SocketReactor component feature that multiplexes many sockets on one select() loop thread and dispatches read handling onto a shared espp::ThreadPool, reducing thread-per-socket overhead while also exposing the reactor in the host C++ library and Python bindings (UDP path).
Changes:
- Implement
SocketReactor(select loop + wakeup socket + thread-pool dispatch) with UDP receiver, TCP listener, TCP stream, and low-level fd registration APIs. - Refactor/extend the socket component for reactor use (
UdpSocket::bind(ReceiveConfig),Socket::native_handle()), plus concurrency-related fixes (inet_ntopusage andTcpSocket::connected_atomics). - Add host/ESP examples, C++/Python tests, documentation updates, and integrate the new source/bindings into the host build.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| python/socket_reactor.py | Python example demonstrating reactor-multiplexed UDP echo receivers. |
| python/socket_reactor_test.py | Self-checking Python test coverage for the Python UDP reactor binding. |
| python/README.md | Documents the new Python example + test scripts. |
| pc/tests/socket_reactor.cpp | Host-side C++ test exercising UDP + TCP reactor paths and lifecycle. |
| lib/python_bindings/socket_reactor_bindings.cpp | Hand-written pybind11 bindings for SocketReactor (UDP path + lifecycle). |
| lib/python_bindings/module.cpp | Registers the new hand-written socket reactor bindings in the module init. |
| lib/include/espp.hpp | Exposes socket_reactor.hpp via the aggregate host include header. |
| lib/espp.cmake | Adds socket_reactor.cpp and the new bindings file to host build targets. |
| lib/autogenerate_bindings.py | Documents why socket_reactor.hpp is excluded from litgen generation. |
| doc/en/network/socket_reactor.rst | Adds Sphinx/Doxygen-facing documentation page for SocketReactor. |
| doc/en/network/index.rst | Adds socket_reactor to the network docs toctree. |
| doc/Doxyfile | Adds socket_reactor.hpp to Doxygen INPUT list. |
| components/socket/src/udp_socket.cpp | Factors out UdpSocket::bind(ReceiveConfig) from start_receiving(). |
| components/socket/src/socket.cpp | Switches IPv4 address formatting to thread-safe inet_ntop. |
| components/socket/src/socket_reactor.cpp | Implements the new SocketReactor runtime. |
| components/socket/README.md | Updates component README with SocketReactor overview and usage notes. |
| components/socket/include/udp_socket.hpp | Declares the new UdpSocket::bind(ReceiveConfig) API. |
| components/socket/include/tcp_socket.hpp | Makes connected_ atomic for reactor/pool concurrency. |
| components/socket/include/socket.hpp | Adds Socket::native_handle() to expose the underlying fd/handle. |
| components/socket/include/socket_reactor.hpp | Adds the public SocketReactor API and documentation. |
| components/socket/idf_component.yml | Adds espp/thread_pool dependency required by reactor. |
| components/socket/example/main/socket_example.cpp | Adds multiple reactor scenarios and expands example summary output. |
| components/socket/CMakeLists.txt | Adds thread_pool to component requirements for ESP builds. |
- udp_socket: fix "alrady" -> "already" typo in the receive-in-progress log. - socket_reactor_bindings: own the Python receive callback via a shared_ptr with a GIL-acquiring deleter, so ~py::function runs under the GIL even when the last reference is released on a reactor pool worker. - socket_reactor: refuse (and log) a stop() called from within a handler instead of deadlocking on the in-flight wait / owned-pool self-join; a thread_local DispatchGuard marks handler execution. Document the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Destroying the reactor from within a handler is a fatal lifetime violation (the handler runs inside the object being freed); the destructor now detects t_in_reactor_dispatch and std::terminate()s with a clear message instead of clearing entries_ under a still-running loop/pool (use-after-free). - dispatch(): decrement in_flight_count_ via an RAII CountGuard so it always runs (early return or a throwing handler), and wrap the handler call in try/catch (guarded by __cpp_exceptions, since ESP-IDF disables exceptions) so a handler exception can't kill the pool worker or leave an entry stuck in_flight and hang stop(). - loop_iteration(): on a failed pool submit, honor remove_requested (erase) instead of blindly re-arming a logically-removed registration. - socket_reactor_bindings: catch py::error_already_set / std::exception in the Python receive-callback wrapper, report and return nullopt so a raising Python callback degrades to "no response" rather than crashing/stalling the worker. - Add a python regression test: a raising callback yields no reply and the reactor keeps serving other receivers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
components/socket/src/socket_reactor.cpp:476
- Same Windows compatibility issue here:
::close(fd)is not the correct way to close a Winsock socket. Useclosesocket()under _MSC_VER, consistent with close_wakeup_socket().
::close(fd);
The create_wakeup_socket() error paths (bind / getsockname failure) closed the raw fd with ::close(), which is wrong for a Winsock socket (closesocket() is required and ::close() may not even compile). Factor the platform-correct close into a close_socket() helper and use it in both create_wakeup_socket() and close_wakeup_socket(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
components/socket/src/socket_reactor.cpp:160
- On Windows (_MSC_VER),
fd_setcapacity is limited by count (FD_SETSIZE), not by the numeric socket value.check_fd()currently skips FD_SETSIZE checks on Windows, so registrations beyond FD_SETSIZE-1 may be silently dropped byFD_SET, leaving entries registered but never dispatched.
bool SocketReactor::check_fd(sock_type_t fd) const {
if (!Socket::is_valid_fd(fd)) {
logger_.error("register: invalid socket fd");
return false;
}
components/socket/src/socket_reactor.cpp:201
add_udp_receiver()binds the socket before validating whether its fd can be registered with the reactor. If registration later fails (e.g. FD_SETSIZE or Windows fd_set capacity), the socket remains bound even though the reactor returns INVALID_ID, which can surprise callers and cause later bind failures.
SocketReactor::add_udp_receiver(espp::UdpSocket &socket,
const espp::UdpSocket::ReceiveConfig &receive_config) {
if (!socket.bind(receive_config)) {
logger_.error("add_udp_receiver: could not bind socket to port {}", receive_config.port);
return INVALID_ID;
}
doc/Doxyfile:392
- The
doc/DoxyfileINPUTlist is kept in alphabetical order for deterministic diffs. The newsocket_reactor.hppentry is appended afterudp_socket.hpp/tcp_socket.hpp; it should be placed in alphabetical order with the other socket headers.
$(PROJECT_PATH)/components/socket/include/socket.hpp \
$(PROJECT_PATH)/components/socket/include/udp_socket.hpp \
$(PROJECT_PATH)/components/socket/include/tcp_socket.hpp \
$(PROJECT_PATH)/components/socket/include/socket_reactor.hpp \
…tart/stop - create_wakeup_socket(): fail (close + return false) if set_nonblocking() fails, instead of ignoring it - a blocking wakeup socket would let the loop thread's drain-recvfrom() loop block forever and stall the reactor. - socket_reactor_bindings: release the GIL around SocketReactor.start()/stop() (py::call_guard<py::gil_scoped_release>). stop() waits for in-flight handlers, and a pool worker running a Python receive callback needs the GIL - holding it in stop() would deadlock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (2)
components/socket/src/socket_reactor.cpp:94
- If loop_task_->start() fails, start() returns false but leaves the owned thread pool potentially running (pool_->start() was already called) and keeps loop_task_ allocated. This can leak worker threads and leaves the reactor in a partially-started state after a failed start(). Clean up on failure by resetting loop_task_ and stopping the owned pool before returning.
if (!loop_task_->start()) {
logger_.error("Could not start reactor loop task");
running_ = false;
close_wakeup_socket();
return false;
doc/Doxyfile:392
- In doc/Doxyfile the INPUT list is generally kept in alphabetical order (e.g., the surrounding rtsp/socket/spi sections). With socket_reactor.hpp added, the socket headers are now out of order, which makes future maintenance harder. Please reorder the socket header entries alphabetically (socket.hpp, socket_reactor.hpp, tcp_socket.hpp, udp_socket.hpp).
$(PROJECT_PATH)/components/socket/include/socket.hpp \
$(PROJECT_PATH)/components/socket/include/udp_socket.hpp \
$(PROJECT_PATH)/components/socket/include/tcp_socket.hpp \
$(PROJECT_PATH)/components/socket/include/socket_reactor.hpp \
Description
Adds
espp::SocketReactor: a singleselect()-based event-loop thread thatmultiplexes many receiver sockets and dispatches each socket's read + user
callback onto a shared
espp::ThreadPool, instead of dedicating one thread(one
espp::Task) to every receiving socket. This collapses "N receiverthreads" into "1 loop thread + a small fixed pool" that can be shared across
subsystems (dns_server, rtps, rtsp, ftp, …).
Design
select(), a readable socketis disarmed and a job is submitted to the pool, then re-armed only after
that job completes — so there is at most one in-flight handler per socket
(per-socket ordering preserved, no concurrent
recvon one fd) whiledifferent sockets run concurrently. If the pool is saturated the socket is
left for the next
select()to re-report (natural backpressure, no loss).add/remove/re-arm/stop immediately responsive.
Config) or anexternal
shared_ptrshared across subsystems.add_udp_receiver()(binds + receives, replacing a per-socketUdpSocket::start_receiving()thread);add_tcp_listener()(accept →dispatch) and
add_tcp_stream()(recv → callback, auto-remove on disconnect)— so a TCP server runs with no accept thread and no thread-per-client; plus a
low-level
add_fd()/remove(). Registration is guarded againstFD_SETSIZE.Supporting changes
UdpSocket::bind(ReceiveConfig)is factored out ofstart_receiving()(binda socket for external-loop use without a thread) and
Socket::native_handle()is added.
TcpSocket::connected_→std::atomic<bool>;Socket::Infousesinet_ntopinto a local buffer instead of
inet_ntoa's shared static buffer (both racedunder concurrent reactor use); the reactor's
stop()waits unbounded forin-flight handlers (a shared-pool use-after-free otherwise), the TCP stream
handler no longer busy-loops on a non-EOF error (e.g. peer RST), and the
wakeup fd is atomic.
Host library exposure (lib)
socket_reactor.cppis built into the C++ static library and the Pythonextension;
socket_reactor.hppis added to theespp.hppaggregate.python_bindings/socket_reactor_bindings.cpp(GIL-safe UDP-receivercallback), registered via
py_init_socket_reactor; the autogenerate scriptdocuments the exclusion. TCP listener/stream paths stay C++-only for now.
Docs & examples/tests
lifecycle / multi-client / UDP-overloads cases (15 pass/fail scenarios) with a
failure summary.
python/socket_reactor.py,python/socket_reactor_test.py, andpc/tests/socket_reactor.cpp.doc/en/network/socket_reactor.rst(+ toctree), andthe Doxyfile are updated.
Motivation and Context
Every receiving socket in espp currently owns a dedicated background thread:
UdpSocket::start_receiving()spins up oneespp::Task, and TCP servers(
ftp_server,rtsp_server) run an accept thread plus one or more threads perconnected client. A server with N clients therefore consumes N+ threads, each
with its own stack — expensive on ESP targets.
SocketReactorlets manysockets share a single
select()loop and a small, bounded thread pool, soadding receivers/clients no longer adds threads. Existing per-socket APIs are
unchanged, so this is purely additive; consumers can migrate incrementally.
How has this been tested?
idf.py buildofcomponents/socket/examplefor esp32 (the example is in the CI build matrix).The expanded suite has 15 self-checking scenarios.
libbuild (C++ staticespp_pc+_esppmodule),then
pc/tests/socket_reactor.cpprun → 23/23 checks pass (lifecycle,multiple UDP receivers, dynamic remove,
add_fdvalidation, shared pool, andthe full TCP listener/stream/disconnect flow).
python/socket_reactor_test.pyrun against the locally-built_espp→ 17/17 checks pass (lifecycle, UDP receiver + reversed echo via aPython callback, sender info,
None-response, multiple receivers, dynamicremove);
python/socket_reactor.pyround-trips datagrams through tworeactor-multiplexed receivers.
CI
static_analysisarguments.socket_reactor.hppwith zero warnings inthe real-build configuration (
EXTRACT_ALL=YES, withtask.hpp/thread_pool.hppinINPUT);\ref/\snippetreferences resolve.Env: macOS host build; ESP-IDF for the esp32 example compile.
Screenshots (if appropriate, e.g. schematic, board, console logs, lab pictures):
N/A
Types of changes
Checklist:
Software
.github/workflows/build.ymlfile to add my new test to the automated cloud build