Skip to content

feat(socket): Add SocketReactor (select() loop + thread pool) and expose it - #688

Merged
finger563 merged 5 commits into
mainfrom
feat/socket-reactor
Aug 5, 2026
Merged

feat(socket): Add SocketReactor (select() loop + thread pool) and expose it#688
finger563 merged 5 commits into
mainfrom
feat/socket-reactor

Conversation

@finger563

Copy link
Copy Markdown
Contributor

Description

Adds espp::SocketReactor: a single select()-based event-loop thread that
multiplexes 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 receiver
threads" into "1 loop thread + a small fixed pool" that can be shared across
subsystems (dns_server, rtps, rtsp, ftp, …).

Design

  • One-shot arm/disarm. Under level-triggered select(), a readable socket
    is 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 recv on one fd) while
    different sockets run concurrently. If the pool is saturated the socket is
    left for the next select() to re-report (natural backpressure, no loss).
  • Wakeup socket. A loopback UDP socket in the select set makes
    add/remove/re-arm/stop immediately responsive.
  • Pool ownership. The thread pool may be owned (built from Config) or an
    external shared_ptr shared across subsystems.
  • APIs. add_udp_receiver() (binds + receives, replacing a per-socket
    UdpSocket::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 against FD_SETSIZE.

Supporting changes

  • UdpSocket::bind(ReceiveConfig) is factored out of start_receiving() (bind
    a socket for external-loop use without a thread) and Socket::native_handle()
    is added.
  • Correctness fixes surfaced by review of the socket component:
    TcpSocket::connected_std::atomic<bool>; Socket::Info uses inet_ntop
    into a local buffer instead of inet_ntoa's shared static buffer (both raced
    under concurrent reactor use); the reactor's stop() waits unbounded for
    in-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.cpp is built into the C++ static library and the Python
    extension; socket_reactor.hpp is added to the espp.hpp aggregate.
  • litgen cannot parse the header, so the Python bindings are hand-written in
    python_bindings/socket_reactor_bindings.cpp (GIL-safe UDP-receiver
    callback), registered via py_init_socket_reactor; the autogenerate script
    documents the exclusion. TCP listener/stream paths stay C++-only for now.

Docs & examples/tests

  • ESP example gains reactor + TCP-reactor scenarios plus shared-pool /
    lifecycle / multi-client / UDP-overloads cases (15 pass/fail scenarios) with a
    failure summary.
  • New host examples/tests: python/socket_reactor.py,
    python/socket_reactor_test.py, and pc/tests/socket_reactor.cpp.
  • Component README, a new doc/en/network/socket_reactor.rst (+ toctree), and
    the Doxyfile are updated.

Motivation and Context

Every receiving socket in espp currently owns a dedicated background thread:
UdpSocket::start_receiving() spins up one espp::Task, and TCP servers
(ftp_server, rtsp_server) run an accept thread plus one or more threads per
connected client. A server with N clients therefore consumes N+ threads, each
with its own stack — expensive on ESP targets. SocketReactor lets many
sockets share a single select() loop and a small, bounded thread pool, so
adding 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?

  • ESP target (compile-only; I can't flash): idf.py build of
    components/socket/example for esp32 (the example is in the CI build matrix).
    The expanded suite has 15 self-checking scenarios.
  • Host C++: standalone lib build (C++ static espp_pc + _espp module),
    then pc/tests/socket_reactor.cpp run → 23/23 checks pass (lifecycle,
    multiple UDP receivers, dynamic remove, add_fd validation, shared pool, and
    the full TCP listener/stream/disconnect flow).
  • Host Python: python/socket_reactor_test.py run against the locally-built
    _espp17/17 checks pass (lifecycle, UDP receiver + reversed echo via a
    Python callback, sender info, None-response, multiple receivers, dynamic
    remove); python/socket_reactor.py round-trips datagrams through two
    reactor-multiplexed receivers.
  • Static analysis: cppcheck clean on the new/changed socket files using the
    CI static_analysis arguments.
  • Docs: Doxygen 1.17 parses socket_reactor.hpp with zero warnings in
    the real-build configuration (EXTRACT_ALL=YES, with task.hpp/
    thread_pool.hpp in INPUT); \ref/\snippet references 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation Update
  • Hardware (schematic, board, system design) change
  • Software change

Checklist:

  • My change requires a change to the documentation.
  • I have added / updated the documentation related to this change via either README or WIKI

Software

  • I have added tests to cover my changes.
  • I have updated the .github/workflows/build.yml file to add my new test to the automated cloud build
  • All new and existing tests passed.
  • My code follows the code style of this project.

…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>
Copilot AI lite review requested due to automatic review settings August 4, 2026 19:55
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ntop usage and TcpSocket::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.

Comment thread components/socket/src/udp_socket.cpp
Comment thread lib/python_bindings/socket_reactor_bindings.cpp Outdated
Comment thread components/socket/src/socket_reactor.cpp
@finger563 finger563 self-assigned this Aug 4, 2026
@finger563 finger563 added enhancement New feature or request socket thread pool labels Aug 4, 2026
- 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>
Copilot AI review requested due to automatic review settings August 4, 2026 21:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Comment thread components/socket/src/socket_reactor.cpp
Comment thread components/socket/src/socket_reactor.cpp
Comment thread components/socket/src/socket_reactor.cpp
Comment thread lib/python_bindings/socket_reactor_bindings.cpp Outdated
- 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>
Copilot AI review requested due to automatic review settings August 4, 2026 23:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Use closesocket() under _MSC_VER, consistent with close_wakeup_socket().
    ::close(fd);

Comment thread components/socket/src/socket_reactor.cpp Outdated
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>
Copilot AI review requested due to automatic review settings August 4, 2026 23:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_set capacity 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 by FD_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/Doxyfile INPUT list is kept in alphabetical order for deterministic diffs. The new socket_reactor.hpp entry is appended after udp_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 \

Comment thread components/socket/src/socket_reactor.cpp Outdated
Comment thread lib/python_bindings/socket_reactor_bindings.cpp Outdated
…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>
Copilot AI review requested due to automatic review settings August 5, 2026 00:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 \

@finger563
finger563 merged commit e013054 into main Aug 5, 2026
138 checks passed
@finger563
finger563 deleted the feat/socket-reactor branch August 5, 2026 01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants