From e99bed68f9c79c9fd9d1c2f5d920faece28deaad Mon Sep 17 00:00:00 2001 From: Guo Date: Fri, 24 Jul 2026 16:35:59 -0500 Subject: [PATCH 1/9] modify espp lib to generate thread pool --- lib/autogenerate_bindings.py | 3 +++ lib/espp.cmake | 2 ++ lib/include/espp.hpp | 1 + 3 files changed, 6 insertions(+) diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index b4d99fe23..43625e6da 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -457,6 +457,9 @@ def autogenerate() -> None: include_dir + "task/include/task.hpp", include_dir + "timer/include/timer.hpp", + # NOTE: this must come after task and base_component since it depends on them + include_dir + "thread_pool/include/thread_pool.hpp", + # NOTE: this must come after vector2d.hpp and range_mapper.hpp since it depends on them! include_dir + "joystick/include/joystick.hpp", diff --git a/lib/espp.cmake b/lib/espp.cmake index 4d0d302d4..78da945bf 100644 --- a/lib/espp.cmake +++ b/lib/espp.cmake @@ -39,6 +39,7 @@ set(ESPP_INCLUDES ${ESPP_COMPONENTS}/serialization/include ${ESPP_COMPONENTS}/tabulate/include ${ESPP_COMPONENTS}/task/include + ${ESPP_COMPONENTS}/thread_pool/include ${ESPP_COMPONENTS}/timer/include ${ESPP_COMPONENTS}/socket/include ${ESPP_COMPONENTS}/state_machine/include @@ -69,6 +70,7 @@ set(ESPP_SOURCES ${ESPP_COMPONENTS}/rtsp/src/mjpeg_depacketizer.cpp ${ESPP_COMPONENTS}/rtsp/src/mjpeg_packetizer.cpp ${ESPP_COMPONENTS}/task/src/task.cpp + ${ESPP_COMPONENTS}/thread_pool/src/thread_pool.cpp ${ESPP_COMPONENTS}/timer/src/timer.cpp ${ESPP_COMPONENTS}/socket/src/socket.cpp ${ESPP_COMPONENTS}/socket/src/tcp_socket.cpp diff --git a/lib/include/espp.hpp b/lib/include/espp.hpp index 064148c5d..37eeff76b 100644 --- a/lib/include/espp.hpp +++ b/lib/include/espp.hpp @@ -52,6 +52,7 @@ extern "C" { #include "tabulate.hpp" #include "task.hpp" #include "tcp_socket.hpp" +#include "thread_pool.hpp" #include "timer.hpp" #include "udp_socket.hpp" #include "vector2d.hpp" From 9519ffa43aab9f897058a908765243479c7f18ae Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 24 Jul 2026 16:41:14 -0500 Subject: [PATCH 2/9] Update to fix implicit default constructor --- lib/autogenerate_bindings.py | 1 + lib/python_bindings/espp/__init__.pyi | 460 +++++++++++++++++--------- lib/python_bindings/pybind_espp.cpp | 161 +++++++-- 3 files changed, 439 insertions(+), 183 deletions(-) diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index 43625e6da..4775d7f9f 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -49,6 +49,7 @@ def _code_preprocess(code: str) -> str: ".def(py::init())\n" " .def(py::init())" ), + "pyClassThreadPool": ".def(py::init())", "pyClassBezier_espp_Vector2f": ( ".def(py::init::Config &>())\n" " .def(py::init::WeightedConfig &>())" diff --git a/lib/python_bindings/espp/__init__.pyi b/lib/python_bindings/espp/__init__.pyi index 86ed499ba..e4ecc9107 100644 --- a/lib/python_bindings/espp/__init__.pyi +++ b/lib/python_bindings/espp/__init__.pyi @@ -23,7 +23,7 @@ class BaseComponent: """ pass - def set_log_tag(self, tag: std.string_view) -> None: + def set_log_tag(self, tag: str) -> None: """/ Set the tag for the logger / \param tag The tag to use for the logger """ @@ -117,7 +117,6 @@ class Cobs: """ pass - @staticmethod @overload def encode_packet(data: std.span[ int], output: std.span[int]) -> int: @@ -131,6 +130,7 @@ class Cobs: """ pass + @staticmethod def max_decoded_size(encoded_len: int) -> int: """* @@ -153,7 +153,6 @@ class Cobs: """ pass - @staticmethod @overload def decode_packet(encoded_data: std.span[ int], output: std.span[int]) -> int: @@ -167,6 +166,7 @@ class Cobs: """ pass + def __init__(self) -> None: """Auto-generated default constructor""" pass @@ -199,7 +199,6 @@ class CobsStreamDecoder: """ pass - @overload def add_data(self, data: List[int]) -> None: """* @@ -210,6 +209,7 @@ class CobsStreamDecoder: """ pass + def extract_packet(self) -> Optional[List[int]]: """* * @brief Try to extract the next complete packet. Removes the extracted data from the buffer. @@ -265,7 +265,6 @@ class CobsStreamEncoder: """ pass - @overload def add_packet(self, data: List[int]) -> None: """* @@ -276,6 +275,7 @@ class CobsStreamEncoder: """ pass + def get_encoded_data(self) -> List[int]: """* * @brief Get all encoded data as a single buffer for debug purposes @@ -295,7 +295,6 @@ class CobsStreamEncoder: """ pass - @overload def extract_data(self, output: int, max_size: int) -> int: """* @@ -308,6 +307,7 @@ class CobsStreamEncoder: """ pass + def buffer_size(self) -> int: """* * @brief Get the current buffer size @@ -346,7 +346,6 @@ class Rgb: @overload def __init__(self) -> None: pass - @overload def __init__(self, r: float, g: float, b: float) -> None: """* @@ -362,7 +361,6 @@ class Rgb: """ pass - @overload def __init__(self, rgb: Rgb) -> None: """* @@ -373,7 +371,6 @@ class Rgb: """ pass - @overload def __init__(self, hsv: Hsv) -> None: """* @@ -387,7 +384,6 @@ class Rgb: """ pass - @overload def __init__(self, hex: int) -> None: """* @@ -400,6 +396,10 @@ class Rgb: + + + + def __add__(self, rhs: Rgb) -> Rgb: """* * @brief Perform additive color blending (averaging) @@ -417,10 +417,10 @@ class Rgb: """ pass - def __eq__(self, rhs: Rgb) -> bool: + def __eq__(self, rhs: object) -> bool: pass - def __ne__(self, rhs: Rgb) -> bool: + def __ne__(self, rhs: object) -> bool: pass def hsv(self) -> Hsv: @@ -451,7 +451,6 @@ class Hsv: @overload def __init__(self) -> None: pass - @overload def __init__(self, h: float, s: float, v: float) -> None: """* @@ -462,7 +461,6 @@ class Hsv: """ pass - @overload def __init__(self, hsv: Hsv) -> None: """* @@ -471,7 +469,6 @@ class Hsv: """ pass - @overload def __init__(self, rgb: Rgb) -> None: """* @@ -483,10 +480,13 @@ class Hsv: pass - def __eq__(self, rhs: Hsv) -> bool: + + + + def __eq__(self, rhs: object) -> bool: pass - def __ne__(self, rhs: Hsv) -> bool: + def __ne__(self, rhs: object) -> bool: pass @@ -590,7 +590,6 @@ class EventManager: """ pass - @overload def add_subscriber( self, @@ -615,6 +614,7 @@ class EventManager: """ pass + def publish(self, topic: str, data: List[int]) -> bool: """* * @brief Publish \p data on \p topic. @@ -661,12 +661,7 @@ class EventManager: class FtpServer: """/ \brief A class that implements a FTP server.""" - def __init__( - self, - ip_address: std.string_view, - port: int, - root: std.filesystem.path - ) -> None: + def __init__(self, ip_address: str, port: int, root: std.filesystem.path) -> None: """/ \brief A class that implements a FTP server. / \note The IP Address is not currently used to select the right / interface, but is instead passed to the FtpClientSession so that @@ -744,17 +739,17 @@ class Logger: * @brief Configuration struct for the logger. """ - tag: std.string_view #*< The TAG that will be prepended to all logs. + tag: str #*< The TAG that will be prepended to all logs. include_time: bool = bool(True) #*< Include the time in the log. rate_limit: std.chrono.duration[float] = std.chrono.duration(0) #*< The rate limit for the logger. Optional, if <= 0 no rate limit. @note Only calls that have _rate_limited suffixed will be rate limited. level: Logger.Verbosity = Logger.Verbosity.warn #*< The verbosity level for the logger. def __init__( self, - tag: std.string_view = std.string_view(), + tag: str = str(), include_time: bool = bool(True), rate_limit: std.chrono.duration[float] = std.chrono.duration(0), - level: Logger.Verbosity = Logger.Verbosity.warn + level: Logger.Verbosity = Logger.Verbosity.WARN ) -> None: """Auto-generated default constructor with named params""" pass @@ -779,7 +774,7 @@ class Logger: """ pass - def set_tag(self, tag: std.string_view) -> None: + def set_tag(self, tag: str) -> None: """* * @brief Change the tag for the logger. * @param tag The new tag. @@ -861,8 +856,8 @@ class Bezier_espp_Vector2f: # Python specialization for Bezier * @note See https://pomax.github.io/bezierinfo/ for information on bezier * curves. * @note Template class which can be used individually on floating point - * values directly or on containers such as Vector2. - * @tparam T The type of the control points, e.g. float or Vector2. + * values directly or on containers such as Vector2d. + * @tparam T The type of the control points, e.g. float or Vector2d. * @note The bezier curve is defined by 4 control points, P0, P1, P2, P3. * The curve is defined by the equation: * \f$B(t) = (1-t)^3 * P0 + 3 * (1-t)^2 * t * P1 + 3 * (1-t) * t^2 * P2 + t^3 * P3\f$ @@ -1023,7 +1018,7 @@ def inv_lerp(a: float, b: float, v: float) -> float: """ pass -def piecewise_linear(points: List[Tuple[float, float]], x: float) -> float: +def piecewise_linear(points: std.span[Tuple[float, float]], x: float) -> float: """* * @brief Compute the piecewise linear interpolation between a set of points. * @param points Vector of points to interpolate between. The vector should be @@ -1057,7 +1052,7 @@ def fast_ln(x: float) -> float: """* * @brief fast natural log function, ln(x). * @note This speed hack comes from: - * https://gist.github.com/LingDong-/7e4c4cae5cbbc44400a05ba650623 + * https://gist.github.com/LingDong-/7e4c4cae5cbbc44400a05fba65f06f23 * @param x Value to take the natural log of. * @return ln(x) @@ -1114,7 +1109,7 @@ class Gaussian: alpha: float = float(1.0) #/< Max amplitude of the gaussian output, defautls to 1.0. beta: float = float(0.5) #/< Beta value for the gaussian, default to be symmetric at 0.5 in range [0,1]. - def __eq__(self, rhs: Gaussian.Config) -> bool: + def __eq__(self, rhs: object) -> bool: pass def __init__( self, @@ -1629,7 +1624,7 @@ class Vector2d_int: # Python specialization for Vector2d * utilities. * * \section vector_ex1 Example - * \snippet math_example.cpp vector2 example + * \snippet math_example.cpp vector2d example """ @overload @@ -1641,7 +1636,6 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - @overload def __init__(self, other: Vector2d) -> None: """* @@ -1653,6 +1647,7 @@ class Vector2d_int: # Python specialization for Vector2d + def magnitude(self) -> int: """* * @brief Returns vector magnitude: ||v||. @@ -1677,7 +1672,6 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - @overload def x(self, v: int) -> None: """* @@ -1687,6 +1681,7 @@ class Vector2d_int: # Python specialization for Vector2d """ pass + @overload def y(self) -> int: """* @@ -1695,7 +1690,6 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - @overload def y(self, v: int) -> None: """* @@ -1705,6 +1699,7 @@ class Vector2d_int: # Python specialization for Vector2d """ pass + def __lt__(self, other: Vector2d) -> bool: """* * @brief Spaceship operator for comparing two vectors. @@ -1723,7 +1718,7 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - def __eq__(self, other: Vector2d) -> bool: + def __eq__(self, other: object) -> bool: """* * @brief Spaceship operator for comparing two vectors. * @param other The vector to compare against. @@ -1751,7 +1746,7 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - def __eq__(self, other: Vector2d) -> bool: + def __eq__(self, other: object) -> bool: """* * @brief Equality operator for comparing two vectors. * @param other The vector to compare against. @@ -1770,7 +1765,6 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - @overload def __neg__(self) -> Vector2d: """* * @brief Negate the vector. @@ -1778,8 +1772,6 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - - @overload def __sub__(self, rhs: Vector2d) -> Vector2d: """* * @brief Return a new vector which is the provided vector subtracted from @@ -1790,6 +1782,7 @@ class Vector2d_int: # Python specialization for Vector2d """ pass + def __isub__(self, rhs: Vector2d) -> Vector2d: """* * @brief Return the provided vector subtracted from this vector. @@ -1847,28 +1840,26 @@ class Vector2d_int: # Python specialization for Vector2d """ pass - @overload - def __itruediv__(self, v: int) -> Vector2d: + def __truediv__(self, v: Vector2d) -> Vector2d: """* - * @brief Return the vector divided by the provided value. - * @param v Value the vector should be divided by. + * @brief Return a scaled version of the vector, divided by the provided + * vector value. Scales x and y independently. + * @param v Vector values the vector should be divided by. * @return Resultant scaled vector. """ pass @overload - def __truediv__(self, v: Vector2d) -> Vector2d: + def __itruediv__(self, v: int) -> Vector2d: """* - * @brief Return a scaled version of the vector, divided by the provided - * vector value. Scales x and y independently. - * @param v Vector values the vector should be divided by. + * @brief Return the vector divided by the provided value. + * @param v Value the vector should be divided by. * @return Resultant scaled vector. """ pass - @overload def __itruediv__(self, v: Vector2d) -> Vector2d: """* @@ -1879,6 +1870,8 @@ class Vector2d_int: # Python specialization for Vector2d """ pass + + def dot(self, other: Vector2d) -> T: """* * @brief Dot product of this vector with another vector. @@ -1909,7 +1902,7 @@ class Vector2d_float: # Python specialization for Vector2d * utilities. * * \section vector_ex1 Example - * \snippet math_example.cpp vector2 example + * \snippet math_example.cpp vector2d example """ @overload @@ -1921,7 +1914,6 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - @overload def __init__(self, other: Vector2d) -> None: """* @@ -1933,6 +1925,7 @@ class Vector2d_float: # Python specialization for Vector2d + def magnitude(self) -> float: """* * @brief Returns vector magnitude: ||v||. @@ -1957,7 +1950,6 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - @overload def x(self, v: float) -> None: """* @@ -1967,6 +1959,7 @@ class Vector2d_float: # Python specialization for Vector2d """ pass + @overload def y(self) -> float: """* @@ -1975,7 +1968,6 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - @overload def y(self, v: float) -> None: """* @@ -1985,6 +1977,7 @@ class Vector2d_float: # Python specialization for Vector2d """ pass + def __lt__(self, other: Vector2d) -> bool: """* * @brief Spaceship operator for comparing two vectors. @@ -2003,7 +1996,7 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - def __eq__(self, other: Vector2d) -> bool: + def __eq__(self, other: object) -> bool: """* * @brief Spaceship operator for comparing two vectors. * @param other The vector to compare against. @@ -2031,7 +2024,7 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - def __eq__(self, other: Vector2d) -> bool: + def __eq__(self, other: object) -> bool: """* * @brief Equality operator for comparing two vectors. * @param other The vector to compare against. @@ -2050,7 +2043,6 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - @overload def __neg__(self) -> Vector2d: """* * @brief Negate the vector. @@ -2058,8 +2050,6 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - - @overload def __sub__(self, rhs: Vector2d) -> Vector2d: """* * @brief Return a new vector which is the provided vector subtracted from @@ -2070,6 +2060,7 @@ class Vector2d_float: # Python specialization for Vector2d """ pass + def __isub__(self, rhs: Vector2d) -> Vector2d: """* * @brief Return the provided vector subtracted from this vector. @@ -2127,28 +2118,26 @@ class Vector2d_float: # Python specialization for Vector2d """ pass - @overload - def __itruediv__(self, v: float) -> Vector2d: + def __truediv__(self, v: Vector2d) -> Vector2d: """* - * @brief Return the vector divided by the provided value. - * @param v Value the vector should be divided by. + * @brief Return a scaled version of the vector, divided by the provided + * vector value. Scales x and y independently. + * @param v Vector values the vector should be divided by. * @return Resultant scaled vector. """ pass @overload - def __truediv__(self, v: Vector2d) -> Vector2d: + def __itruediv__(self, v: float) -> Vector2d: """* - * @brief Return a scaled version of the vector, divided by the provided - * vector value. Scales x and y independently. - * @param v Vector values the vector should be divided by. + * @brief Return the vector divided by the provided value. + * @param v Value the vector should be divided by. * @return Resultant scaled vector. """ pass - @overload def __itruediv__(self, v: Vector2d) -> Vector2d: """* @@ -2159,6 +2148,8 @@ class Vector2d_float: # Python specialization for Vector2d """ pass + + def dot(self, other: Vector2d) -> T: """* * @brief Dot product of this vector with another vector. @@ -2419,12 +2410,7 @@ class Ndef: handover_version: int = 0x13 #/< Connection Handover version 1.3 # (C++ static member) # (const) - def __init__( - self, - tnf: Ndef.TNF, - type: std.string_view, - payload: std.string_view - ) -> None: + def __init__(self, tnf: Ndef.TNF, type: str, payload: str) -> None: """* * @brief Makes an NDEF record with header and payload. * @param tnf The TNF for this packet. @@ -2435,7 +2421,7 @@ class Ndef: pass @staticmethod - def make_text(text: std.string_view) -> Ndef: + def make_text(text: str) -> Ndef: """* * @brief Static function to make an NDEF record for transmitting english * text. @@ -2446,7 +2432,7 @@ class Ndef: pass @staticmethod - def make_uri(uri: std.string_view, uic: Ndef.Uic = Ndef.Uic.none) -> Ndef: + def make_uri(uri: str, uic: Ndef.Uic = Ndef.Uic.none) -> Ndef: """* * @brief Static function to make an NDEF record for loading a URI. * @param uri URI for the record to point to. @@ -2457,7 +2443,7 @@ class Ndef: pass @staticmethod - def make_android_launcher(uri: std.string_view) -> Ndef: + def make_android_launcher(uri: str) -> Ndef: """* * @brief Static function to make an NDEF record for launching an Android App. * @param uri URI for the android package / app to launch. @@ -2471,18 +2457,18 @@ class Ndef: * @brief Configuration structure for wifi configuration ndef structure. """ - ssid: std.string_view #/< SSID for the network - key: std.string_view #/< Security key / password for the network + ssid: str #/< SSID for the network + key: str #/< Security key / password for the network authentication: Ndef.WifiAuthenticationType = Ndef.WifiAuthenticationType.wpa2_personal #/< Authentication type the network #/< uses. encryption: Ndef.WifiEncryptionType = Ndef.WifiEncryptionType.aes #/< Encryption type the network uses. mac_address: int = 0xFFFFFFFFFFFF #/< Broadcast MAC address FF:FF:FF:FF:FF:FF def __init__( self, - ssid: std.string_view = std.string_view(), - key: std.string_view = std.string_view(), - authentication: Ndef.WifiAuthenticationType = Ndef.WifiAuthenticationType.wpa2_personal, - encryption: Ndef.WifiEncryptionType = Ndef.WifiEncryptionType.aes, + ssid: str = str(), + key: str = str(), + authentication: Ndef.WifiAuthenticationType = Ndef.WifiAuthenticationType.WPA2_PERSONAL, + encryption: Ndef.WifiEncryptionType = Ndef.WifiEncryptionType.AES, mac_address: int = 0xFFFFFFFFFFFF ) -> None: """Auto-generated default constructor with named params""" @@ -2557,15 +2543,15 @@ class Ndef: def make_oob_pairing( mac_addr: int, device_class: int, - name: std.string_view, - random_value: std.string_view = "", - confirm_value: std.string_view = "" + name: str, + random_value: str = "", + confirm_value: str = "" ) -> Ndef: """* * @brief Static function to make an NDEF record for BT classic OOB Pairing (Android). * @param mac_addr 48 bit MAC Address of the BT radio * @note If the address is e.g. f4:12:fa:42:fe:9e then the mac_addr should be - * 0xf412a42e9e. + * 0xf412fa42fe9e. * @param device_class The bluetooth device class for this radio. * @param name Name of the BT device. * @param random_value The Simple pairing randomizer R for the pairing. @@ -2580,17 +2566,17 @@ class Ndef: def make_le_oob_pairing( mac_addr: int, role: Ndef.BleRole, - name: std.string_view = "", + name: str = "", appearance: Ndef.BtAppearance = Ndef.BtAppearance.unknown, - random_value: std.string_view = "", - confirm_value: std.string_view = "", - tk: std.string_view = "" + random_value: str = "", + confirm_value: str = "", + tk: str = "" ) -> Ndef: """* * @brief Static function to make an NDEF record for BLE OOB Pairing (Android). * @param mac_addr 48 bit MAC Address of the BLE radio. * @note If the address is e.g. f4:12:fa:42:fe:9e then the mac_addr should be - * 0xf412a42e9e. + * 0xf412fa42fe9e. * @param role The BLE role of the device (central / peripheral / dual) * @param name Name of the BLE device. Optional. * @param appearance BtAppearance of the device. Optional. @@ -2680,7 +2666,7 @@ class Pid: output_min: float #*< Limit the minimum output value. Can be a different magnitude from output max for asymmetric output behavior. output_max: float #*< Limit the maximum output value. - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #*< Verbosity for the adc logger. + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #*< Verbosity for the adc logger. def __init__( self, kp: float = float(), @@ -2690,7 +2676,7 @@ class Pid: integrator_max: float = float(), output_min: float = float(), output_max: float = float(), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -2813,7 +2799,6 @@ class Socket: """ pass - @overload def from_sockaddr(self, source_address: struct sockaddr_in) -> None: """* @@ -2822,7 +2807,6 @@ class Socket: """ pass - @overload def from_sockaddr(self, source_address: struct sockaddr_in6) -> None: """* @@ -2831,6 +2815,8 @@ class Socket: """ pass + + def __init__(self, address: str = "", port: int = int()) -> None: """Auto-generated default constructor with named params""" pass @@ -2980,10 +2966,10 @@ class TcpSocket: * @brief Config struct for the TCP socket. """ - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #*< Verbosity level for the TCP socket logger. + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #*< Verbosity level for the TCP socket logger. def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -3088,7 +3074,6 @@ class TcpSocket: """ pass - @overload def transmit( self, @@ -3109,11 +3094,10 @@ class TcpSocket: """ pass - @overload def transmit( self, - data: std.string_view, + data: str, transmit_config: TcpSocket.TransmitConfig = TcpSocket.TransmitConfig.Default() ) -> bool: """* @@ -3130,7 +3114,6 @@ class TcpSocket: """ pass - @overload def transmit( self, @@ -3152,6 +3135,9 @@ class TcpSocket: """ pass + + + @overload def receive(self, data: List[int], max_num_bytes: int) -> bool: """* @@ -3164,7 +3150,6 @@ class TcpSocket: """ pass - @overload def receive(self, data: int, max_num_bytes: int) -> int: """* @@ -3180,6 +3165,7 @@ class TcpSocket: """ pass + def bind(self, port: int) -> bool: """* * @brief Bind the socket as a server on \p port. @@ -3304,10 +3290,10 @@ class UdpSocket: pass class Config: - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #*< Verbosity level for the UDP socket logger. + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #*< Verbosity level for the UDP socket logger. def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -3338,9 +3324,8 @@ class UdpSocket: """ pass - @overload - def send(self, data: std.string_view, send_config: UdpSocket.SendConfig) -> bool: + def send(self, data: str, send_config: UdpSocket.SendConfig) -> bool: """* * @brief Send data to the endpoint specified by the send_config. * Can be configured to multicast (within send_config) and can be @@ -3359,7 +3344,6 @@ class UdpSocket: """ pass - @overload def send(self, data: std.span[ int], send_config: UdpSocket.SendConfig) -> bool: """* @@ -3381,6 +3365,8 @@ class UdpSocket: """ pass + + def receive( self, max_num_bytes: int, @@ -3500,12 +3486,12 @@ class Task: """ callback: Task.callback_variant #*< Callback function task_config: Task.BaseConfig #*< Base configuration for the task. - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #*< Log verbosity for the task. + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #*< Log verbosity for the task. def __init__( self, callback: Task.callback_variant = Task.callback_variant(), task_config: Task.BaseConfig = Task.BaseConfig(), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -3602,7 +3588,6 @@ class Task: """ pass - @staticmethod @overload def get_id(task: Task) -> task_id_t: @@ -3615,6 +3600,7 @@ class Task: """ pass + @staticmethod def get_current_id() -> task_id_t: """* @@ -3684,7 +3670,7 @@ class Timer: class Config: """/ @brief The configuration for the timer.""" - name: std.string_view #/< The name of the timer. + name: str #/< The name of the timer. period: std.chrono.duration[float] #/< The period of the timer. If 0, the timer callback will only be called once. delay: std.chrono.duration[float] = std.chrono.duration( 0) #/< The delay before the first execution of the timer callback after start() is called. @@ -3693,10 +3679,10 @@ class Timer: stack_size_bytes: int = int(4096) #/< The stack size of the task that runs the timer. priority: int = int(0) #/< Priority of the timer, 0 is lowest priority on ESP / FreeRTOS. core_id: int = int(-1) #/< Core ID of the timer, -1 means it is not pinned to any core. - log_level: Logger.Verbosity = Logger.Verbosity.warn #/< The log level for the timer. + log_level: Logger.Verbosity = Logger.Verbosity.WARN #/< The log level for the timer. def __init__( self, - name: std.string_view = std.string_view(), + name: str = str(), period: std.chrono.duration[float] = std.chrono.duration(), delay: std.chrono.duration[float] = std.chrono.duration( 0), @@ -3705,7 +3691,7 @@ class Timer: stack_size_bytes: int = int(4096), priority: int = int(0), core_id: int = int(-1), - log_level: Logger.Verbosity = Logger.Verbosity.warn + log_level: Logger.Verbosity = Logger.Verbosity.WARN ) -> None: """Auto-generated default constructor with named params""" pass @@ -3718,7 +3704,7 @@ class Timer: callback: Timer.callback_fn #/< The callback function to call when the timer expires. auto_start: bool = bool(True) #/< If True, the timer will start automatically when constructed. task_config: Task.BaseConfig #/< The task configuration for the timer. - log_level: Logger.Verbosity = Logger.Verbosity.warn #/< The log level for the timer. + log_level: Logger.Verbosity = Logger.Verbosity.WARN #/< The log level for the timer. def __init__( self, period: std.chrono.duration[float] = std.chrono.duration(), @@ -3727,7 +3713,7 @@ class Timer: callback: Timer.callback_fn = Timer.callback_fn(), auto_start: bool = bool(True), task_config: Task.BaseConfig = Task.BaseConfig(), - log_level: Logger.Verbosity = Logger.Verbosity.warn + log_level: Logger.Verbosity = Logger.Verbosity.WARN ) -> None: """Auto-generated default constructor with named params""" pass @@ -3741,7 +3727,6 @@ class Timer: / @details Starts the timer. Does nothing if the timer is already running. """ pass - @overload def start(self, delay: std.chrono.duration[float]) -> None: """/ @brief Start the timer with a delay. @@ -3754,6 +3739,7 @@ class Timer: """ pass + def stop(self) -> None: """/ @brief Stop the timer, same as cancel(). / @details Stops the timer, same as cancel(). @@ -3792,6 +3778,153 @@ class Timer: #################### #################### +#################### #################### + + + + +class ThreadPool: + """* + * @brief A thread pool that dispatches submitted jobs to a fixed set of worker threads. + * + * Workers are implemented as espp::Task instances. Jobs are queued and + * consumed in FIFO order. The queue can be optionally bounded; when full, + * new submissions are either rejected immediately or blocked until space + * becomes available, depending on the configuration. + * + * \section thread_pool_ex1 Lifecycle: start / stop / is_running / worker_count + * \snippet thread_pool_example.cpp lifecycle example + * \section thread_pool_ex2 Submit Jobs + * \snippet thread_pool_example.cpp submit example + * \section thread_pool_ex3 try_submit — Non-Blocking Rejection When Full + * \snippet thread_pool_example.cpp try_submit example + * \section thread_pool_ex4 Blocking Submit When Full + * \snippet thread_pool_example.cpp blocking submit example + * \section thread_pool_ex5 Submit Rejected After stop() + * \snippet thread_pool_example.cpp submit after stop example + * \section thread_pool_ex6 Concurrent start / stop + * \snippet thread_pool_example.cpp concurrent lifecycle example + * \section thread_pool_ex7 Concurrent submit and try_submit + * \snippet thread_pool_example.cpp concurrent submit example + * \section thread_pool_ex8 Chained Pools + * \snippet thread_pool_example.cpp chained pools example + * \section thread_pool_ex9 Self-Submit + * \snippet thread_pool_example.cpp self-submit example + + """ + + class Stats: + """/ @brief Snapshot of pool activity counters.""" + submitted: std.int = 0 #/< Total jobs accepted into the queue. + executed: std.int = 0 #/< Total jobs successfully executed. + rejected: std.int = 0 #/< Total jobs rejected (invalid job, stopped/stopping, or queue full) or dropped (due to stop, the enqueued jobs were dropped). + def __init__( + self, + submitted: std.int = 0, + executed: std.int = 0, + rejected: std.int = 0 + ) -> None: + """Auto-generated default constructor with named params""" + pass + + class Config: + """/ @brief Configuration parameters for constructing a ThreadPool.""" + worker_count: std.int = 1 #/< Number of worker threads to spawn. + max_queue_size: std.int = 0 #/< Maximum pending jobs (0 = unbounded). + auto_start: bool = True #/< Start workers immediately on construction. + block_on_submit_when_full: bool = False #/< If True, submit() blocks when the queue is full instead of rejecting. + worker_task_config: Task.BaseConfig = Task.BaseConfig( ///< Base configuration applied to every worker task. + .name = "thread_pool_worker", + .stack_size_bytes = 4096, + .priority = 5, + .core_id = -1, + ) + log_level: Logger.Verbosity = Logger.Verbosity.WARN #/< Logger verbosity level. + def __init__( + self, + worker_count: std.int = 1, + max_queue_size: std.int = 0, + auto_start: bool = True, + block_on_submit_when_full: bool = False, + worker_task_config: Task.BaseConfig = Task.BaseConfig(///< Base configuration applied to every worker task. + .name = "thread_pool_worker", + .stack_size_bytes = 4096, + .priority = 5, + .core_id = -1, + ), + log_level: Logger.Verbosity = Logger.Verbosity.WARN + ) -> None: + """Auto-generated default constructor with named params""" + pass + + + + def start(self) -> bool: + """/ @brief Start all worker threads. + / @return True if all workers were successfully started, False otherwise. + / @note No-op if the pool is already running and return True immediately. + / @note If any workers could not be started, the pool will roll back to the stopped state. + """ + pass + + def stop(self) -> None: + """/ @brief Stop all worker threads and reject further submissions. + / @note Blocks until every worker has exited; queued jobs may not be executed. + """ + pass + + def is_running(self) -> bool: + """/ @brief Query whether the pool is currently running. + / @return True if workers are active, False otherwise. + """ + pass + + def submit(self, job: Job) -> bool: + """/ @brief Submit a job, optionally blocking when the queue is full. + / + / Blocks if Config::block_on_submit_when_full is True and the queue has + / reached its capacity limit. Otherwise behaves identically to try_submit(). + / @param job Callable to enqueue; moved into the queue on acceptance. + / @return True if the job was accepted, False if it was rejected. + """ + pass + + def try_submit(self, job: Job) -> bool: + """/ @brief Attempt to submit a job without blocking. + / + / Returns immediately with False when the queue is full. + / @param job Callable to enqueue; moved into the queue on acceptance. + / @return True if the job was accepted, False if it was rejected. + """ + pass + + def queue_size(self) -> std.int: + """/ @brief Return the number of jobs currently waiting in the queue. + / @return Pending job count. + """ + pass + + def worker_count(self) -> std.int: + """/ @brief Return the number of worker threads in the pool. + / @return Worker thread count. + """ + pass + + def stats(self) -> ThreadPool.Stats: + """/ @brief Return a snapshot of the pool's activity counters. + / @return Stats struct with submitted, executed, and rejected counts. + """ + pass + + def __init__(self) -> None: + """Auto-generated default constructor""" + pass + + + +#################### #################### + + #################### #################### @@ -3845,16 +3978,16 @@ class Joystick: you want to use update(), unused if you call update(float raw_x, float raw_y). - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #*< Verbosity for the Joystick logger_. + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #*< Verbosity for the Joystick logger_. def __init__( self, x_calibration: FloatRangeMapper.Config = FloatRangeMapper.Config(), y_calibration: FloatRangeMapper.Config = FloatRangeMapper.Config(), - type: Joystick.Type = Joystick.Type(Joystick.Type.rectangular), + type: Joystick.Type = Joystick.Type(Joystick.Type.RECTANGULAR), center_deadzone_radius: float = float(0), range_deadzone: float = float(0), get_values: Joystick.get_values_fn = Joystick.get_values_fn(None), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -3985,7 +4118,6 @@ class Joystick: """ pass - @overload def update(self, raw_x: float, raw_y: float) -> None: """* @@ -3999,6 +4131,7 @@ class Joystick: """ pass + def x(self) -> float: """* * @brief Get the most recently updated x axis calibrated position. @@ -4085,10 +4218,10 @@ class RtpDepacketizer: class Config: """/ Configuration for RtpDepacketizer.""" - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4130,11 +4263,11 @@ class RtpPacketizer: class Config: """/ Configuration for RtpPacketizer.""" max_payload_size: int = int(1400) #/< Maximum payload bytes per RTP packet - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, max_payload_size: int = int(1400), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4200,10 +4333,10 @@ class GenericDepacketizer: """ class Config: """/ Configuration for GenericDepacketizer.""" - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4250,7 +4383,7 @@ class GenericPacketizer: channels: int = int(1) #/< Number of audio channels fmtp: str #/< Optional format parameters for SDP fmtp line media_type: MediaType = MediaType(MediaType.audio) #/< Media type for the SDP m= line - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, max_payload_size: int = int(1400), @@ -4259,8 +4392,8 @@ class GenericPacketizer: encoding_name: str = str("L16"), channels: int = int(1), fmtp: str = "", - media_type: MediaType = MediaType(MediaType.audio), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + media_type: MediaType = MediaType(MediaType.AUDIO), + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4330,10 +4463,10 @@ class H264Depacketizer: """ class Config: """/ Configuration for the H264Depacketizer.""" - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4389,7 +4522,7 @@ class H264Packetizer: packetization_mode: int = int(1) #/< 0 = single NAL only, 1 = non-interleaved (FU-A allowed). sps: List[int] #/< Sequence Parameter Set raw bytes (without start code). pps: List[int] #/< Picture Parameter Set raw bytes (without start code). - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, max_payload_size: int = int(1400), @@ -4398,7 +4531,7 @@ class H264Packetizer: packetization_mode: int = int(1), sps: List[int] = List[int](), pps: List[int] = List[int](), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4486,10 +4619,10 @@ class MjpegDepacketizer: class Config: """/ Configuration for the MJPEG depacketizer.""" - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4523,11 +4656,11 @@ class MjpegPacketizer: class Config: """/ Configuration for the MJPEG packetizer.""" max_payload_size: int = int(1400) #/< Maximum payload bytes per RTP packet - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) #/< Log verbosity level + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) #/< Log verbosity level def __init__( self, max_payload_size: int = int(1400), - log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.warn) + log_level: Logger.Verbosity = Logger.Verbosity(Logger.Verbosity.WARN) ) -> None: """Auto-generated default constructor with named params""" pass @@ -4579,7 +4712,6 @@ class RtpJpegPacket: / @param data The buffer containing the RTP packet. """ pass - @overload def __init__( self, @@ -4605,7 +4737,6 @@ class RtpJpegPacket: / @param scan_data The scan data. """ pass - @overload def __init__( self, @@ -4631,6 +4762,8 @@ class RtpJpegPacket: pass + + def get_type_specific(self) -> int: """/ Get the type-specific field. / @return The type-specific field. @@ -4734,7 +4867,6 @@ class JpegFrame: / @param packet The packet to parse. """ pass - @overload def __init__(self, data: List[int]) -> None: """/ Construct a JpegFrame from a vector of jpeg data. @@ -4743,7 +4875,6 @@ class JpegFrame: / header and EOI marker. """ pass - @overload def __init__(self, data: std.span[ int]) -> None: """/ Construct a JpegFrame from a span of jpeg data. @@ -4752,7 +4883,6 @@ class JpegFrame: / header and EOI marker. """ pass - @overload def __init__(self, data: int, size: int) -> None: """/ Construct a JpegFrame from buffer of jpeg data @@ -4761,6 +4891,9 @@ class JpegFrame: """ pass + + + def get_header(self) -> JpegHeader: """/ Get a reference to the header. / @return A reference to the header. @@ -4792,7 +4925,6 @@ class JpegFrame: """ pass - @overload def add_scan(self, packet: RtpJpegPacket) -> None: """/ Append a JPEG scan to the frame. / This will add the JPEG data to the frame. @@ -4845,13 +4977,13 @@ class JpegHeader: / @param q1_table The quantization table for the Cb and Cr channels. """ pass - @overload def __init__(self, data: std.span[ int]) -> None: """/ Create a JPEG header from a given JPEG header data.""" pass + def get_width(self) -> int: """/ Get the image width. / @return The image width in pixels. @@ -4906,7 +5038,7 @@ class RtcpPacket: pass - def get_data(self) -> std.string_view: + def get_data(self) -> str: """/ @brief Get the buffer of the packet / @return The buffer of the packet """ @@ -4931,14 +5063,12 @@ class RtpPacket: / The packet_ vector is empty and the header fields are set to 0. """ pass - @overload def __init__(self, payload_size: int) -> None: """/ Construct an RtpPacket with a payload of size payload_size. / The packet_ vector is resized to RTP_HEADER_SIZE + payload_size. """ pass - @overload def __init__(self, data: std.span[ int]) -> None: """/ Construct an RtpPacket from a span of bytes. @@ -4948,6 +5078,8 @@ class RtpPacket: pass + + # ----------------------------------------------------------------- # Getters for the RTP header fields. # ----------------------------------------------------------------- @@ -5186,7 +5318,7 @@ class RtspClient: #/ and re-enter service discovery or reconnect logic automatically. on_connection_lost: disconnect_callback_t = disconnect_callback_t(None) - log_level: Logger.Verbosity = Logger.Verbosity.info #/< The verbosity of the logger + log_level: Logger.Verbosity = Logger.Verbosity.INFO #/< The verbosity of the logger def __init__( self, server_address: str = "", @@ -5195,7 +5327,7 @@ class RtspClient: on_frame: frame_callback_t = frame_callback_t(None), on_jpeg_frame: jpeg_frame_callback_t = jpeg_frame_callback_t(None), on_connection_lost: disconnect_callback_t = disconnect_callback_t(None), - log_level: Logger.Verbosity = Logger.Verbosity.info + log_level: Logger.Verbosity = Logger.Verbosity.INFO ) -> None: """Auto-generated default constructor with named params""" pass @@ -5264,7 +5396,6 @@ class RtspClient: / \param ec The error code to set if an error occurs """ pass - @overload def setup( self, @@ -5283,6 +5414,7 @@ class RtspClient: """ pass + def add_depacketizer(self, payload_type: int, depacketizer: RtpDepacketizer) -> None: """/ Register a depacketizer for a specific RTP payload type. / When RTP packets with this payload type are received, they are @@ -5355,7 +5487,7 @@ class RtspServer: #/< up into multiple packets if they are larger than this. It seems that 1500 works #/< well for sending, but is too large for the esp32 (camera-display) to receive #/< properly. - log_level: Logger.Verbosity = Logger.Verbosity.warn #/< The log level for the RTSP server + log_level: Logger.Verbosity = Logger.Verbosity.WARN #/< The log level for the RTSP server accept_task_stack_size_bytes: int = default_accept_task_stack_size_bytes #/< RTSP accept-task stack size, in bytes session_task_stack_size_bytes: int = default_session_task_stack_size_bytes #/< RTSP session-dispatch task stack size, in bytes control_task_stack_size_bytes: int = RtspSession.Config.default_control_task_stack_size_bytes #/< Per-session RTSP @@ -5367,7 +5499,7 @@ class RtspServer: port: int = int(), path: str = "", max_data_size: int = 1000, - log_level: Logger.Verbosity = Logger.Verbosity.warn, + log_level: Logger.Verbosity = Logger.Verbosity.WARN, accept_task_stack_size_bytes: int = default_accept_task_stack_size_bytes, session_task_stack_size_bytes: int = default_session_task_stack_size_bytes, control_task_stack_size_bytes: int = RtspSession.Config.default_control_task_stack_size_bytes @@ -5450,7 +5582,6 @@ class RtspServer: / @param frame_data Raw encoded frame data. """ pass - @overload def send_frame(self, frame: JpegFrame) -> None: """/ @brief Send a JPEG frame over the RTSP connection (backward compatible). @@ -5461,7 +5592,6 @@ class RtspServer: / @param frame The frame to send. """ pass - @overload def send_frame(self, frame_data: std.span[ int]) -> None: """/ @brief Send raw JPEG bytes over the default MJPEG track. @@ -5472,6 +5602,8 @@ class RtspServer: """ pass + + def __init__(self) -> None: """Auto-generated default constructor""" pass @@ -5516,14 +5648,14 @@ class RtspSession: #/ @param session_id The session ID #/ @param server_address The server address with port sdp_generator: std.function[str( str session_path, int session_id, str server_address)] - log_level: Logger.Verbosity = Logger.Verbosity.warn #/< The log level of the session + log_level: Logger.Verbosity = Logger.Verbosity.WARN #/< The log level of the session def __init__( self, server_address: str = "", rtsp_path: str = "", receive_timeout: std.chrono.duration[float] = std.chrono.seconds(5), control_task_stack_size_bytes: int = default_control_task_stack_size_bytes, - log_level: Logger.Verbosity = Logger.Verbosity.warn + log_level: Logger.Verbosity = Logger.Verbosity.WARN ) -> None: """Auto-generated default constructor with named params""" pass @@ -5583,7 +5715,6 @@ class RtspSession: / @return True if the packet was sent successfully, False otherwise """ pass - @overload def send_rtp_packet(self, track_id: int, packet_data: std.span[ int]) -> bool: """/ Send a serialized RTP packet on a specific track. @@ -5592,7 +5723,6 @@ class RtspSession: / @return True if the packet was sent successfully, False otherwise """ pass - @overload def send_rtp_packet(self, packet: RtpPacket) -> bool: """/ Send an RTP packet to the client (backward compat — sends on default track 0) @@ -5600,7 +5730,6 @@ class RtspSession: / @return True if the packet was sent successfully, False otherwise """ pass - @overload def send_rtp_packet(self, packet_data: std.span[ int]) -> bool: """/ Send a serialized RTP packet to the client (default track 0). @@ -5609,6 +5738,9 @@ class RtspSession: """ pass + + + @overload def send_rtcp_packet(self, track_id: int, packet: RtcpPacket) -> bool: """/ Send an RTCP packet on a specific track @@ -5617,7 +5749,6 @@ class RtspSession: / @return True if the packet was sent successfully, False otherwise """ pass - @overload def send_rtcp_packet(self, packet: RtcpPacket) -> bool: """/ Send an RTCP packet to the client (backward compat — sends on default track 0) @@ -5626,6 +5757,7 @@ class RtspSession: """ pass + def __init__(self) -> None: """Auto-generated default constructor""" pass @@ -5686,7 +5818,6 @@ class LowpassFilter: """ pass - @overload def update(self, input: float) -> float: """* @@ -5698,6 +5829,7 @@ class LowpassFilter: """ pass + def __call__(self, input: float) -> float: """* * @brief Filter the signal sampled by input, updating internal state, and diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index ca9582c64..30ed93500 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -458,8 +458,8 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Implements rational / weighted and unweighted cubic bezier curves\n * " "between control points.\n * @note See https://pomax.github.io/bezierinfo/ for information " "on bezier\n * curves.\n * @note Template class which can be used individually on " - "floating point\n * values directly or on containers such as Vector2.\n * " - "@tparam T The type of the control points, e.g. float or Vector2.\n * @note The " + "floating point\n * values directly or on containers such as Vector2d.\n * " + "@tparam T The type of the control points, e.g. float or Vector2d.\n * @note The " "bezier curve is defined by 4 control points, P0, P1, P2, P3.\n * The curve is defined " "by the equation:\n * \\f$B(t) = (1-t)^3 * P0 + 3 * (1-t)^2 * t * P1 + 3 * (1-t) * t^2 " "* P2 + t^3 * P3\\f$\n * where t is the evaluation parameter, [0, 1].\n *\n * @note The " @@ -563,7 +563,7 @@ void py_init_module_espp(py::module &m) { m.def("fast_ln", espp::fast_ln, py::arg("x"), "*\n * @brief fast natural log function, ln(x).\n * @note This speed hack comes from:\n * " - " https://gist.github.com/LingDong-/7e4c4cae5cbbc44400a05ba650623\n * @param x Value to " + " https://gist.github.com/LingDong-/7e4c4cae5cbbc44400a05fba65f06f23\n * @param x Value to " "take the natural log of.\n * @return ln(x)\n"); m.def("fast_sin", espp::fast_sin, py::arg("angle"), @@ -895,7 +895,7 @@ void py_init_module_espp(py::module &m) { m, "Vector2d_int", py::dynamic_attr(), "*\n * @brief Container representing a 2 dimensional vector.\n *\n * Provides " "getters/setters, index operator, and vector / scalar math\n * utilities.\n *\n * " - "\\section vector_ex1 Example\n * \\snippet math_example.cpp vector2 example\n") + "\\section vector_ex1 Example\n * \\snippet math_example.cpp vector2d example\n") .def(py::init(), py::arg("x") = 0, py::arg("y") = 0, "*\n * @brief Constructor for the vector, defaults to 0,0.\n * @param x The " "starting X value.\n * @param y The starting Y value.\n") @@ -1014,10 +1014,6 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Return a scaled version of the vector, divided by the provided\n " "* value.\n * @param v Value the vector should be divided by.\n * " "@return Resultant scaled vector.\n") - .def("__itruediv__", py::overload_cast(&espp::Vector2d::operator/=), - py::arg("v"), - "*\n * @brief Return the vector divided by the provided value.\n * @param v " - "Value the vector should be divided by.\n * @return Resultant scaled vector.\n") .def("__truediv__", py::overload_cast &>(&espp::Vector2d::operator/, py::const_), @@ -1025,6 +1021,10 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Return a scaled version of the vector, divided by the provided\n " "* vector value. Scales x and y independently.\n * @param v Vector values " "the vector should be divided by.\n * @return Resultant scaled vector.\n") + .def("__itruediv__", py::overload_cast(&espp::Vector2d::operator/=), + py::arg("v"), + "*\n * @brief Return the vector divided by the provided value.\n * @param v " + "Value the vector should be divided by.\n * @return Resultant scaled vector.\n") .def("__itruediv__", py::overload_cast &>(&espp::Vector2d::operator/=), py::arg("v"), @@ -1042,7 +1042,7 @@ void py_init_module_espp(py::module &m) { m, "Vector2d_float", py::dynamic_attr(), "*\n * @brief Container representing a 2 dimensional vector.\n *\n * Provides " "getters/setters, index operator, and vector / scalar math\n * utilities.\n *\n * " - "\\section vector_ex1 Example\n * \\snippet math_example.cpp vector2 example\n") + "\\section vector_ex1 Example\n * \\snippet math_example.cpp vector2d example\n") .def(py::init(), py::arg("x") = 0, py::arg("y") = 0, "*\n * @brief Constructor for the vector, defaults to 0,0.\n * @param x The " "starting X value.\n * @param y The starting Y value.\n") @@ -1161,10 +1161,6 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Return a scaled version of the vector, divided by the provided\n " "* value.\n * @param v Value the vector should be divided by.\n * " "@return Resultant scaled vector.\n") - .def("__itruediv__", py::overload_cast(&espp::Vector2d::operator/=), - py::arg("v"), - "*\n * @brief Return the vector divided by the provided value.\n * @param v " - "Value the vector should be divided by.\n * @return Resultant scaled vector.\n") .def("__truediv__", py::overload_cast &>(&espp::Vector2d::operator/, py::const_), @@ -1172,6 +1168,10 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Return a scaled version of the vector, divided by the provided\n " "* vector value. Scales x and y independently.\n * @param v Vector values " "the vector should be divided by.\n * @return Resultant scaled vector.\n") + .def("__itruediv__", py::overload_cast(&espp::Vector2d::operator/=), + py::arg("v"), + "*\n * @brief Return the vector divided by the provided value.\n * @param v " + "Value the vector should be divided by.\n * @return Resultant scaled vector.\n") .def("__itruediv__", py::overload_cast &>(&espp::Vector2d::operator/=), py::arg("v"), @@ -1491,11 +1491,11 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Static function to make an NDEF record for BT classic OOB " "Pairing (Android).\n * @param mac_addr 48 bit MAC Address of the BT radio\n " "* @note If the address is e.g. f4:12:fa:42:fe:9e then the mac_addr should be\n " - " * 0xf412a42e9e.\n * @param device_class The bluetooth device class for " - "this radio.\n * @param name Name of the BT device.\n * @param random_value " - "The Simple pairing randomizer R for the pairing.\n * @param confirm_value The " - "Simple pairing hash C (confirm value) for the\n * " - "pairing.\n * @return NDEF record object.\n") + " * 0xf412fa42fe9e.\n * @param device_class The bluetooth device class " + "for this radio.\n * @param name Name of the BT device.\n * @param " + "random_value The Simple pairing randomizer R for the pairing.\n * @param " + "confirm_value The Simple pairing hash C (confirm value) for the\n * " + " pairing.\n * @return NDEF record object.\n") .def_static( "make_le_oob_pairing", &espp::Ndef::make_le_oob_pairing, py::arg("mac_addr"), py::arg("role"), py::arg("name") = "", @@ -1503,7 +1503,7 @@ void py_init_module_espp(py::module &m) { py::arg("confirm_value") = "", py::arg("tk") = "", "*\n * @brief Static function to make an NDEF record for BLE OOB Pairing (Android).\n " " * @param mac_addr 48 bit MAC Address of the BLE radio.\n * @note If the address is " - "e.g. f4:12:fa:42:fe:9e then the mac_addr should be\n * 0xf412a42e9e.\n * " + "e.g. f4:12:fa:42:fe:9e then the mac_addr should be\n * 0xf412fa42fe9e.\n * " "@param role The BLE role of the device (central / peripheral / dual)\n * @param name " "Name of the BLE device. Optional.\n * @param appearance BtAppearance of the device. " "Optional.\n * @param random_value The Simple pairing randomizer R for the pairing. " @@ -2355,6 +2355,129 @@ void py_init_module_espp(py::module &m) { "@return True if the timer is running, False otherwise."); //////////////////// //////////////////// + //////////////////// //////////////////// + auto pyClassThreadPool = py::class_( + m, "ThreadPool", py::dynamic_attr(), + "*\n * @brief A thread pool that dispatches submitted jobs to a fixed set of worker " + "threads.\n *\n * Workers are implemented as espp::Task instances. Jobs are queued and\n * " + "consumed in FIFO order. The queue can be optionally bounded; when full,\n * new submissions " + "are either rejected immediately or blocked until space\n * becomes available, depending on " + "the configuration.\n *\n * \\section thread_pool_ex1 Lifecycle: start / stop / is_running / " + "worker_count\n * \\snippet thread_pool_example.cpp lifecycle example\n * \\section " + "thread_pool_ex2 Submit Jobs\n * \\snippet thread_pool_example.cpp submit example\n * " + "\\section thread_pool_ex3 try_submit — Non-Blocking Rejection When Full\n * \\snippet " + "thread_pool_example.cpp try_submit example\n * \\section thread_pool_ex4 Blocking Submit " + "When Full\n * \\snippet thread_pool_example.cpp blocking submit example\n * \\section " + "thread_pool_ex5 Submit Rejected After stop()\n * \\snippet thread_pool_example.cpp submit " + "after stop example\n * \\section thread_pool_ex6 Concurrent start / stop\n * \\snippet " + "thread_pool_example.cpp concurrent lifecycle example\n * \\section thread_pool_ex7 " + "Concurrent submit and try_submit\n * \\snippet thread_pool_example.cpp concurrent submit " + "example\n * \\section thread_pool_ex8 Chained Pools\n * \\snippet thread_pool_example.cpp " + "chained pools example\n * \\section thread_pool_ex9 Self-Submit\n * \\snippet " + "thread_pool_example.cpp self-submit example\n"); + + { // inner classes & enums of ThreadPool + auto pyClassThreadPool_ClassStats = + py::class_(pyClassThreadPool, "Stats", py::dynamic_attr(), + "/ @brief Snapshot of pool activity counters.") + .def(py::init<>([](std::uint64_t submitted = 0, std::uint64_t executed = 0, + std::uint64_t rejected = 0) { + auto r_ctor_ = std::make_unique(); + r_ctor_->submitted = submitted; + r_ctor_->executed = executed; + r_ctor_->rejected = rejected; + return r_ctor_; + }), + py::arg("submitted") = 0, py::arg("executed") = 0, py::arg("rejected") = 0) + .def_readwrite("submitted", &espp::ThreadPool::Stats::submitted, + "/< Total jobs accepted into the queue.") + .def_readwrite("executed", &espp::ThreadPool::Stats::executed, + "/< Total jobs successfully executed.") + .def_readwrite("rejected", &espp::ThreadPool::Stats::rejected, + "/< Total jobs rejected (invalid job, stopped/stopping, or queue full) " + "or dropped (due to stop, the enqueued jobs were dropped)."); + auto pyClassThreadPool_ClassConfig = + py::class_( + pyClassThreadPool, "Config", py::dynamic_attr(), + "/ @brief Configuration parameters for constructing a ThreadPool.") + .def(py::init<>([](std::size_t worker_count = 1, std::size_t max_queue_size = 0, + bool auto_start = true, bool block_on_submit_when_full = false, + espp::Task::BaseConfig worker_task_config = + { + ///< Base configuration applied to every worker task. + .name = "thread_pool_worker", + .stack_size_bytes = 4096, + .priority = 5, + .core_id = -1, + }, + espp::Logger::Verbosity log_level = espp::Logger::Verbosity::WARN) { + auto r_ctor_ = std::make_unique(); + r_ctor_->worker_count = worker_count; + r_ctor_->max_queue_size = max_queue_size; + r_ctor_->auto_start = auto_start; + r_ctor_->block_on_submit_when_full = block_on_submit_when_full; + r_ctor_->worker_task_config = worker_task_config; + r_ctor_->log_level = log_level; + return r_ctor_; + }), + py::arg("worker_count") = 1, py::arg("max_queue_size") = 0, + py::arg("auto_start") = true, py::arg("block_on_submit_when_full") = false, + py::arg("worker_task_config") = + espp::Task::BaseConfig{ + ///< Base configuration applied to every worker task. + .name = "thread_pool_worker", + .stack_size_bytes = 4096, + .priority = 5, + .core_id = -1, + }, + py::arg("log_level") = espp::Logger::Verbosity::WARN) + .def_readwrite("worker_count", &espp::ThreadPool::Config::worker_count, + "/< Number of worker threads to spawn.") + .def_readwrite("max_queue_size", &espp::ThreadPool::Config::max_queue_size, + "/< Maximum pending jobs (0 = unbounded).") + .def_readwrite("auto_start", &espp::ThreadPool::Config::auto_start, + "/< Start workers immediately on construction.") + .def_readwrite( + "block_on_submit_when_full", &espp::ThreadPool::Config::block_on_submit_when_full, + "/< If True, submit() blocks when the queue is full instead of rejecting.") + .def_readwrite("worker_task_config", &espp::ThreadPool::Config::worker_task_config, "") + .def_readwrite("log_level", &espp::ThreadPool::Config::log_level, + "/< Logger verbosity level."); + } // end of inner classes & enums of ThreadPool + + pyClassThreadPool.def(py::init()) + .def("start", &espp::ThreadPool::start, + "/ @brief Start all worker threads.\n/ @return True if all workers were successfully " + "started, False otherwise.\n/ @note No-op if the pool is already running and return " + "True immediately.\n/ @note If any workers could not be started, the pool will roll " + "back to the stopped state.") + .def("stop", &espp::ThreadPool::stop, + "/ @brief Stop all worker threads and reject further submissions.\n/ @note Blocks until " + "every worker has exited; queued jobs may not be executed.") + .def("is_running", &espp::ThreadPool::is_running, + "/ @brief Query whether the pool is currently running.\n/ @return True if workers are " + "active, False otherwise.") + .def("submit", &espp::ThreadPool::submit, py::arg("job"), + "/ @brief Submit a job, optionally blocking when the queue is full.\n/\n/ Blocks if " + "Config::block_on_submit_when_full is True and the queue has\n/ reached its capacity " + "limit. Otherwise behaves identically to try_submit().\n/ @param job Callable to " + "enqueue; moved into the queue on acceptance.\n/ @return True if the job was accepted, " + "False if it was rejected.") + .def("try_submit", &espp::ThreadPool::try_submit, py::arg("job"), + "/ @brief Attempt to submit a job without blocking.\n/\n/ Returns immediately with " + "False when the queue is full.\n/ @param job Callable to enqueue; moved into the queue " + "on acceptance.\n/ @return True if the job was accepted, False if it was rejected.") + .def("queue_size", &espp::ThreadPool::queue_size, + "/ @brief Return the number of jobs currently waiting in the queue.\n/ @return Pending " + "job count.") + .def("worker_count", &espp::ThreadPool::worker_count, + "/ @brief Return the number of worker threads in the pool.\n/ @return Worker thread " + "count.") + .def("stats", &espp::ThreadPool::stats, + "/ @brief Return a snapshot of the pool's activity counters.\n/ @return Stats struct " + "with submitted, executed, and rejected counts."); + //////////////////// //////////////////// + //////////////////// //////////////////// auto pyClassJoystick = py::class_( m, "Joystick", py::dynamic_attr(), From fefeae4b79a5e9c8cbc484bd51bd3de8cdccaef0 Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 13:29:48 -0500 Subject: [PATCH 3/9] add test for pc and python; fix utf-8 coding issue for windows --- .../include/esp32-p4-function-ev-board.hpp | 10 +- .../m5stack-tab5/include/m5stack-tab5.hpp | 2 +- components/rtps/include/rtps.hpp | 2 +- components/rtsp/include/h264_depacketizer.hpp | 2 +- components/rtsp/include/h264_packetizer.hpp | 6 +- components/rtsp/include/rtsp_client.hpp | 2 +- components/rtsp/include/rtsp_session.hpp | 4 +- .../st7123touch/include/st7123touch.hpp | 6 +- .../thread_pool/include/thread_pool.hpp | 17 +- components/touch/include/touch.hpp | 8 +- lib/python_bindings/espp/__init__.pyi | 14 +- lib/python_bindings/pybind_espp.cpp | 12 +- pc/tests/thread_pool.cpp | 409 ++++++++++++++ python/thread_pool.py | 534 ++++++++++++++++++ 14 files changed, 987 insertions(+), 41 deletions(-) create mode 100644 pc/tests/thread_pool.cpp create mode 100644 python/thread_pool.py diff --git a/components/esp32-p4-function-ev-board/include/esp32-p4-function-ev-board.hpp b/components/esp32-p4-function-ev-board/include/esp32-p4-function-ev-board.hpp index e6383ed98..f2aa9aabc 100644 --- a/components/esp32-p4-function-ev-board/include/esp32-p4-function-ev-board.hpp +++ b/components/esp32-p4-function-ev-board/include/esp32-p4-function-ev-board.hpp @@ -50,7 +50,7 @@ namespace espp { /// - ES8311 audio codec (+ NS4150B speaker amplifier) over I2S /// - 10/100 Ethernet (EMAC + IP101 RMII PHY) /// - microSD card (4-bit SDMMC) -/// - MIPI-CSI camera (SC2336/OV5647) — pins wired, capture pipeline is a stub +/// - MIPI-CSI camera (SC2336/OV5647) - pins wired, capture pipeline is a stub /// /// \note The BOOT button cannot be used simultaneously with the ethernet PHY, /// since the BOOT button is connected to the PHY's RMII_TXD1 pin. If you @@ -380,12 +380,12 @@ class Esp32P4FunctionEvBoard : public BaseComponent { #endif // CONFIG_ESP_P4_EV_BOARD_ETHERNET || defined(_DOXYGEN_) ///////////////////////////////////////////////////////////////////////////// - // Camera (MIPI-CSI) — pins wired, capture pipeline is a stub + // Camera (MIPI-CSI) - pins wired, capture pipeline is a stub ///////////////////////////////////////////////////////////////////////////// /// Initialize the MIPI-CSI camera (SC2336/OV5647). /// \return True if successful - /// \note Not yet implemented — the camera pins/SCCB are documented in this + /// \note Not yet implemented - the camera pins/SCCB are documented in this /// BSP but the esp_video capture pipeline is not wired up. This always /// returns false for now. bool initialize_camera(); @@ -482,7 +482,7 @@ class Esp32P4FunctionEvBoard : public BaseComponent { static constexpr gpio_num_t internal_i2c_sda = GPIO_NUM_7; static constexpr gpio_num_t internal_i2c_scl = GPIO_NUM_8; - // Touch (GT911) — interrupt/reset are NOT connected on this board + // Touch (GT911) - interrupt/reset are NOT connected on this board static constexpr uint8_t gt911_default_address = 0x5D; static constexpr uint8_t gt911_backup_address = 0x14; @@ -510,7 +510,7 @@ class Esp32P4FunctionEvBoard : public BaseComponent { static constexpr gpio_num_t sd_d3_io = GPIO_NUM_42; ///////////////////////////////////////////////////////////////////////////// - // Camera (MIPI-CSI) — SCCB shares the internal I2C bus; reset/xclk not connected + // Camera (MIPI-CSI) - SCCB shares the internal I2C bus; reset/xclk not connected ///////////////////////////////////////////////////////////////////////////// static constexpr gpio_num_t camera_reset_io = GPIO_NUM_NC; static constexpr gpio_num_t camera_xclk_io = GPIO_NUM_NC; diff --git a/components/m5stack-tab5/include/m5stack-tab5.hpp b/components/m5stack-tab5/include/m5stack-tab5.hpp index e9c34c7c9..e9db0f893 100755 --- a/components/m5stack-tab5/include/m5stack-tab5.hpp +++ b/components/m5stack-tab5/include/m5stack-tab5.hpp @@ -693,7 +693,7 @@ class M5StackTab5 : public BaseComponent { .scl_pullup_en = GPIO_PULLUP_ENABLE, .timeout_ms = 200, // Standard-mode (100 kHz) shared internal bus. At 400 kHz the - // bus is marginal with this many devices on it — the ST7123 + // bus is marginal with this many devices on it - the ST7123 // touch reads time out and a hung transaction corrupts a // concurrent BMI270 read (correlated touch I/O errors + IMU // vector jumps while dragging). Per-device speed mixing diff --git a/components/rtps/include/rtps.hpp b/components/rtps/include/rtps.hpp index d869aef47..487bb9de6 100644 --- a/components/rtps/include/rtps.hpp +++ b/components/rtps/include/rtps.hpp @@ -230,7 +230,7 @@ class RtpsParticipant : public BaseComponent { uint16_t participant_id{0}; ///< RTPS participant ID used for GUID and port derivation. bool randomize_guid_prefix{true}; ///< When true (default), mix per-instance entropy into the ///< participant GUID so a restarted participant is seen as a - ///< new participant — DDS/ROS 2 peers then accept its + ///< new participant - DDS/ROS 2 peers then accept its ///< republished samples instead of dropping them as ///< already-seen duplicates. Set false for a deterministic ///< GUID derived only from node_name/domain/participant id diff --git a/components/rtsp/include/h264_depacketizer.hpp b/components/rtsp/include/h264_depacketizer.hpp index ea30ad2a3..d9f414370 100755 --- a/components/rtsp/include/h264_depacketizer.hpp +++ b/components/rtsp/include/h264_depacketizer.hpp @@ -13,7 +13,7 @@ namespace espp { /// @brief RTP depacketizer for H.264 video per RFC 6184. /// /// Reassembles H.264 access units from incoming RTP packets. Supports: -/// - **Single NAL unit** packets (NAL type 1–23) +/// - **Single NAL unit** packets (NAL type 1-23) /// - **STAP-A** aggregation packets (NAL type 24) /// - **FU-A** fragmentation packets (NAL type 28) /// diff --git a/components/rtsp/include/h264_packetizer.hpp b/components/rtsp/include/h264_packetizer.hpp index 569a22fb5..663c75074 100644 --- a/components/rtsp/include/h264_packetizer.hpp +++ b/components/rtsp/include/h264_packetizer.hpp @@ -16,8 +16,8 @@ namespace espp { /// of RTP payload chunks suitable for transmission. /// /// Supports two NAL-unit packetization strategies: -/// - **Single NAL unit mode** — NAL fits within max_payload_size. -/// - **FU-A fragmentation** — NAL exceeds max_payload_size (packetization_mode >= 1). +/// - **Single NAL unit mode** - NAL fits within max_payload_size. +/// - **FU-A fragmentation** - NAL exceeds max_payload_size (packetization_mode >= 1). /// /// @note This class does not manage RTP headers (sequence numbers, timestamps, /// SSRC). The caller wraps each returned chunk into an RtpPacket. @@ -29,7 +29,7 @@ class H264Packetizer : public RtpPacketizer { /// Configuration for the H264Packetizer. struct Config { size_t max_payload_size{1400}; ///< Maximum payload bytes per RTP packet - int payload_type{96}; ///< Dynamic RTP payload type (typically 96–127). + int payload_type{96}; ///< Dynamic RTP payload type (typically 96-127). std::string profile_level_id; ///< H.264 profile-level-id hex string, e.g. "42C01E". int packetization_mode{1}; ///< 0 = single NAL only, 1 = non-interleaved (FU-A allowed). std::vector sps; ///< Sequence Parameter Set raw bytes (without start code). diff --git a/components/rtsp/include/rtsp_client.hpp b/components/rtsp/include/rtsp_client.hpp index 42cac114e..451ca5bb9 100755 --- a/components/rtsp/include/rtsp_client.hpp +++ b/components/rtsp/include/rtsp_client.hpp @@ -47,7 +47,7 @@ class RtspClient : public BaseComponent { /// Function type for the callback to call when a JPEG frame is received typedef std::function jpeg_frame)> jpeg_frame_callback_t; - /// Generic frame callback — called for any track/codec with raw frame data + /// Generic frame callback - called for any track/codec with raw frame data using frame_callback_t = std::function &&data)>; /// Callback invoked when the RTSP server disappears after playback starts. diff --git a/components/rtsp/include/rtsp_session.hpp b/components/rtsp/include/rtsp_session.hpp index f47b0d6bb..2e0e3a930 100644 --- a/components/rtsp/include/rtsp_session.hpp +++ b/components/rtsp/include/rtsp_session.hpp @@ -123,7 +123,7 @@ class RtspSession : public BaseComponent { /// @return True if the packet was sent successfully, false otherwise bool send_rtp_packet(int track_id, std::span packet_data); - /// Send an RTP packet to the client (backward compat — sends on default track 0) + /// Send an RTP packet to the client (backward compat - sends on default track 0) /// @param packet The RTP packet to send /// @return True if the packet was sent successfully, false otherwise bool send_rtp_packet(const espp::RtpPacket &packet); @@ -139,7 +139,7 @@ class RtspSession : public BaseComponent { /// @return True if the packet was sent successfully, false otherwise bool send_rtcp_packet(int track_id, const espp::RtcpPacket &packet); - /// Send an RTCP packet to the client (backward compat — sends on default track 0) + /// Send an RTCP packet to the client (backward compat - sends on default track 0) /// @param packet The RTCP packet to send /// @return True if the packet was sent successfully, false otherwise bool send_rtcp_packet(const espp::RtcpPacket &packet); diff --git a/components/st7123touch/include/st7123touch.hpp b/components/st7123touch/include/st7123touch.hpp index 635b08669..8a6b2c5b7 100755 --- a/components/st7123touch/include/st7123touch.hpp +++ b/components/st7123touch/include/st7123touch.hpp @@ -18,7 +18,7 @@ namespace espp { /// @note The ST7123's touch engine is gated by the LCD reset (LCD_RST) line, /// NOT the TP_RST line used by standalone touch controllers such as the /// GT911. When used in a system that has a separate TP_RST signal -/// (e.g. M5Stack Tab5), do NOT toggle TP_RST for this chip — doing so +/// (e.g. M5Stack Tab5), do NOT toggle TP_RST for this chip - doing so /// may knock the touch I2C endpoint offline. /// /// Touch data reading sequence (based on ST7123 TDDI Interface Protocol): @@ -79,7 +79,7 @@ class St7123Touch : public BasePeripheral { return false; if (!(adv_info & ADV_INFO_WITH_COORD)) { - // No coordinate data in this interrupt — clear touch state so LVGL sees + // No coordinate data in this interrupt - clear touch state so LVGL sees // the finger as lifted. num_touch_points_ = 0; x_ = 0; @@ -155,7 +155,7 @@ class St7123Touch : public BasePeripheral { } /// @brief Get the home-button state - /// @return Always false — the ST7123 does not expose a home button via I2C + /// @return Always false - the ST7123 does not expose a home button via I2C bool get_home_button_state() const { return false; } protected: diff --git a/components/thread_pool/include/thread_pool.hpp b/components/thread_pool/include/thread_pool.hpp index 41fd9336d..4e3302a9e 100644 --- a/components/thread_pool/include/thread_pool.hpp +++ b/components/thread_pool/include/thread_pool.hpp @@ -28,7 +28,7 @@ namespace espp { * \snippet thread_pool_example.cpp lifecycle example * \section thread_pool_ex2 Submit Jobs * \snippet thread_pool_example.cpp submit example - * \section thread_pool_ex3 try_submit — Non-Blocking Rejection When Full + * \section thread_pool_ex3 try_submit - Non-Blocking Rejection When Full * \snippet thread_pool_example.cpp try_submit example * \section thread_pool_ex4 Blocking Submit When Full * \snippet thread_pool_example.cpp blocking submit example @@ -52,16 +52,19 @@ class ThreadPool : public espp::BaseComponent { struct Stats { std::uint64_t submitted = 0; ///< Total jobs accepted into the queue. std::uint64_t executed = 0; ///< Total jobs successfully executed. - std::uint64_t rejected = 0; ///< Total jobs rejected (invalid job, stopped/stopping, or queue full) or dropped (due to stop, the enqueued jobs were dropped). + std::uint64_t rejected = 0; ///< Total jobs rejected (invalid job, stopped/stopping, or queue + ///< full) or dropped (due to stop, the enqueued jobs were dropped). }; /// @brief Configuration parameters for constructing a ThreadPool. struct Config { - std::size_t worker_count = 1; ///< Number of worker threads to spawn. - std::size_t max_queue_size = 0; ///< Maximum pending jobs (0 = unbounded). - bool auto_start = true; ///< Start workers immediately on construction. - bool block_on_submit_when_full = false; ///< If true, submit() blocks when the queue is full instead of rejecting. - espp::Task::BaseConfig worker_task_config = { ///< Base configuration applied to every worker task. + std::size_t worker_count = 1; ///< Number of worker threads to spawn. + std::size_t max_queue_size = 0; ///< Maximum pending jobs (0 = unbounded). + bool auto_start = true; ///< Start workers immediately on construction. + bool block_on_submit_when_full = + false; ///< If true, submit() blocks when the queue is full instead of rejecting. + espp::Task::BaseConfig worker_task_config = { + ///< Base configuration applied to every worker task. .name = "thread_pool_worker", .stack_size_bytes = 4096, .priority = 5, diff --git a/components/touch/include/touch.hpp b/components/touch/include/touch.hpp index 1ed1a8234..2e9273353 100644 --- a/components/touch/include/touch.hpp +++ b/components/touch/include/touch.hpp @@ -110,9 +110,9 @@ class ITouchDevice { /// standard espp touch-driver interface. /// /// A type T satisfies TouchDriverConcept if it provides: -/// - `bool T::update(std::error_code &)` — read new touch data from hardware -/// - `void T::get_touch_point(uint8_t *, uint16_t *, uint16_t *) const` — retrieve coordinates -/// - `bool T::get_home_button_state() const` — return home-button state +/// - `bool T::update(std::error_code &)` - read new touch data from hardware +/// - `void T::get_touch_point(uint8_t *, uint16_t *, uint16_t *) const` - retrieve coordinates +/// - `bool T::get_home_button_state() const` - return home-button state /// /// Both `espp::Gt911` and `espp::St7123Touch` satisfy this concept. template @@ -174,7 +174,7 @@ template struct TouchDriverAdapter : ITouchDriver { /// @brief Convenience factory: wrap a shared_ptr to a concrete touch driver in /// a `TouchDriverAdapter` and return it as `std::shared_ptr`. -/// @tparam T Concrete driver type — must satisfy `TouchDriverConcept`. +/// @tparam T Concrete driver type - must satisfy `TouchDriverConcept`. template std::shared_ptr make_touch_driver(std::shared_ptr driver) { return std::make_shared>(std::move(driver)); diff --git a/lib/python_bindings/espp/__init__.pyi b/lib/python_bindings/espp/__init__.pyi index e4ecc9107..d236cbb5b 100644 --- a/lib/python_bindings/espp/__init__.pyi +++ b/lib/python_bindings/espp/__init__.pyi @@ -3796,7 +3796,7 @@ class ThreadPool: * \snippet thread_pool_example.cpp lifecycle example * \section thread_pool_ex2 Submit Jobs * \snippet thread_pool_example.cpp submit example - * \section thread_pool_ex3 try_submit — Non-Blocking Rejection When Full + * \section thread_pool_ex3 try_submit - Non-Blocking Rejection When Full * \snippet thread_pool_example.cpp try_submit example * \section thread_pool_ex4 Blocking Submit When Full * \snippet thread_pool_example.cpp blocking submit example @@ -4450,7 +4450,7 @@ class H264Depacketizer: """/ @brief RTP depacketizer for H.264 video per RFC 6184. / / Reassembles H.264 access units from incoming RTP packets. Supports: - / - **Single NAL unit** packets (NAL type 1–23) + / - **Single NAL unit** packets (NAL type 1-23) / - **STAP-A** aggregation packets (NAL type 24) / - **FU-A** fragmentation packets (NAL type 28) / @@ -4505,8 +4505,8 @@ class H264Packetizer: / of RTP payload chunks suitable for transmission. / / Supports two NAL-unit packetization strategies: - / - **Single NAL unit mode** — NAL fits within max_payload_size. - / - **FU-A fragmentation** — NAL exceeds max_payload_size (packetization_mode >= 1). + / - **Single NAL unit mode** - NAL fits within max_payload_size. + / - **FU-A fragmentation** - NAL exceeds max_payload_size (packetization_mode >= 1). / / @note This class does not manage RTP headers (sequence numbers, timestamps, / SSRC). The caller wraps each returned chunk into an RtpPacket. @@ -4517,7 +4517,7 @@ class H264Packetizer: class Config: """/ Configuration for the H264Packetizer.""" max_payload_size: int = int(1400) #/< Maximum payload bytes per RTP packet - payload_type: int = int(96) #/< Dynamic RTP payload type (typically 96–127). + payload_type: int = int(96) #/< Dynamic RTP payload type (typically 96-127). profile_level_id: str #/< H.264 profile-level-id hex string, e.g. "42C01E". packetization_mode: int = int(1) #/< 0 = single NAL only, 1 = non-interleaved (FU-A allowed). sps: List[int] #/< Sequence Parameter Set raw bytes (without start code). @@ -5725,7 +5725,7 @@ class RtspSession: pass @overload def send_rtp_packet(self, packet: RtpPacket) -> bool: - """/ Send an RTP packet to the client (backward compat — sends on default track 0) + """/ Send an RTP packet to the client (backward compat - sends on default track 0) / @param packet The RTP packet to send / @return True if the packet was sent successfully, False otherwise """ @@ -5751,7 +5751,7 @@ class RtspSession: pass @overload def send_rtcp_packet(self, packet: RtcpPacket) -> bool: - """/ Send an RTCP packet to the client (backward compat — sends on default track 0) + """/ Send an RTCP packet to the client (backward compat - sends on default track 0) / @param packet The RTCP packet to send / @return True if the packet was sent successfully, False otherwise """ diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index 30ed93500..7e2ec92c9 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -2365,7 +2365,7 @@ void py_init_module_espp(py::module &m) { "the configuration.\n *\n * \\section thread_pool_ex1 Lifecycle: start / stop / is_running / " "worker_count\n * \\snippet thread_pool_example.cpp lifecycle example\n * \\section " "thread_pool_ex2 Submit Jobs\n * \\snippet thread_pool_example.cpp submit example\n * " - "\\section thread_pool_ex3 try_submit — Non-Blocking Rejection When Full\n * \\snippet " + "\\section thread_pool_ex3 try_submit - Non-Blocking Rejection When Full\n * \\snippet " "thread_pool_example.cpp try_submit example\n * \\section thread_pool_ex4 Blocking Submit " "When Full\n * \\snippet thread_pool_example.cpp blocking submit example\n * \\section " "thread_pool_ex5 Submit Rejected After stop()\n * \\snippet thread_pool_example.cpp submit " @@ -2869,7 +2869,7 @@ void py_init_module_espp(py::module &m) { m, "H264Depacketizer", py::dynamic_attr(), "/ @brief RTP depacketizer for H.264 video per RFC 6184.\n/\n/ Reassembles H.264 access " "units from incoming RTP packets. Supports:\n/ - **Single NAL unit** packets (NAL type " - "1–23)\n/ - **STAP-A** aggregation packets (NAL type 24)\n/ - **FU-A** fragmentation " + "1-23)\n/ - **STAP-A** aggregation packets (NAL type 24)\n/ - **FU-A** fragmentation " "packets (NAL type 28)\n/\n/ When the RTP marker bit is set, the accumulated NAL units are " "delivered\n/ as one Annex B byte-stream (each NAL prefixed with 0x00 0x00 0x00 0x01)\n/ via " "the frame callback set with set_frame_callback().\n/\n/ \\section h264_depacketizer_ex1 " @@ -2907,7 +2907,7 @@ void py_init_module_espp(py::module &m) { "in Annex B byte-stream format (NAL units\n/ separated by 0x00000001 or 0x000001 start " "codes) and produces a sequence\n/ of RTP payload chunks suitable for " "transmission.\n/\n/ Supports two NAL-unit packetization strategies:\n/ - **Single NAL " - "unit mode** — NAL fits within max_payload_size.\n/ - **FU-A fragmentation** — NAL " + "unit mode** - NAL fits within max_payload_size.\n/ - **FU-A fragmentation** - NAL " "exceeds max_payload_size (packetization_mode >= 1).\n/\n/ @note This class does not " "manage RTP headers (sequence numbers, timestamps,\n/ SSRC). The caller wraps each " "returned chunk into an RtpPacket.\n/\n/ \\section h264_packetizer_ex1 Example\n/ " @@ -2942,7 +2942,7 @@ void py_init_module_espp(py::module &m) { .def_readwrite("max_payload_size", &espp::H264Packetizer::Config::max_payload_size, "/< Maximum payload bytes per RTP packet") .def_readwrite("payload_type", &espp::H264Packetizer::Config::payload_type, - "/< Dynamic RTP payload type (typically 96–127).") + "/< Dynamic RTP payload type (typically 96-127).") .def_readwrite("profile_level_id", &espp::H264Packetizer::Config::profile_level_id, "/< H.264 profile-level-id hex string, e.g. \"42C01E\".") .def_readwrite("packetization_mode", &espp::H264Packetizer::Config::packetization_mode, @@ -3685,7 +3685,7 @@ void py_init_module_espp(py::module &m) { .def("send_rtp_packet", py::overload_cast(&espp::RtspSession::send_rtp_packet), py::arg("packet"), - "/ Send an RTP packet to the client (backward compat — sends on default track 0)\n/ " + "/ Send an RTP packet to the client (backward compat - sends on default track 0)\n/ " "@param packet The RTP packet to send\n/ @return True if the packet was sent " "successfully, False otherwise", py::call_guard()) @@ -3706,7 +3706,7 @@ void py_init_module_espp(py::module &m) { .def("send_rtcp_packet", py::overload_cast(&espp::RtspSession::send_rtcp_packet), py::arg("packet"), - "/ Send an RTCP packet to the client (backward compat — sends on default track 0)\n/ " + "/ Send an RTCP packet to the client (backward compat - sends on default track 0)\n/ " "@param packet The RTCP packet to send\n/ @return True if the packet was sent " "successfully, False otherwise", py::call_guard()); diff --git a/pc/tests/thread_pool.cpp b/pc/tests/thread_pool.cpp new file mode 100644 index 000000000..fcf354366 --- /dev/null +++ b/pc/tests/thread_pool.cpp @@ -0,0 +1,409 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "thread_pool.hpp" + +using namespace std::chrono_literals; + +int main() { + espp::Logger logger({.tag = "ThreadPool Test", .level = espp::Logger::Verbosity::INFO}); + + int total_passed = 0; + int total_tests = 0; + + auto check = [&](bool condition, const std::string &desc) -> bool { + ++total_tests; + if (condition) { + ++total_passed; + logger.info(" PASS: {}", desc); + } else { + logger.error(" FAIL: {}", desc); + } + return condition; + }; + + // --------------------------------------------------------------------------- + // 1. Lifecycle: start / stop / is_running / worker_count + // --------------------------------------------------------------------------- + logger.info("--- lifecycle ---"); + { + espp::ThreadPool pool({ + .worker_count = 2, + .auto_start = false, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + check(!pool.is_running(), "not running before start()"); + check(pool.worker_count() == 2, "worker_count() == 2"); + check(pool.start(), "start() returns true"); + check(pool.is_running(), "is_running() after start()"); + check(pool.start(), "duplicate start() is no-op, returns true"); + pool.stop(); + check(!pool.is_running(), "not running after stop()"); + } + + // --------------------------------------------------------------------------- + // 2. submit() + queue_size() + stats() + // --------------------------------------------------------------------------- + logger.info("--- submit + stats ---"); + { + std::mutex mtx; + std::condition_variable cv; + std::atomic done{0}; + constexpr int N = 6; + + espp::ThreadPool pool({ + .worker_count = 2, + .auto_start = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + for (int i = 0; i < N; ++i) { + pool.submit(espp::ThreadPool::Job([&]() { + std::this_thread::sleep_for(20ms); + ++done; + cv.notify_one(); + })); + } + { + std::unique_lock lk(mtx); + cv.wait(lk, [&] { return done.load() >= N; }); + } + + auto s = pool.stats(); + logger.info(" stats: submitted={} executed={} rejected={}", s.submitted, s.executed, + s.rejected); + check(s.submitted == N, "submitted == N"); + check(s.executed == N, "executed == N"); + check(s.rejected == 0, "rejected == 0"); + check(pool.queue_size() == 0, "queue empty after all jobs finish"); + pool.stop(); + } + + // --------------------------------------------------------------------------- + // 3. try_submit() — deterministic rejection when queue is full + // --------------------------------------------------------------------------- + logger.info("--- try_submit bounded queue ---"); + { + std::mutex barrier_mtx; + std::condition_variable barrier_cv; + bool release = false; + std::mutex started_mtx; + std::condition_variable started_cv; + std::atomic started{0}; + + espp::ThreadPool pool({ + .worker_count = 1, + .max_queue_size = 2, + .auto_start = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + // Submit first job and wait until it is executing (off the queue) + pool.try_submit(espp::ThreadPool::Job([&]() { + { + std::lock_guard lk(started_mtx); + ++started; + } + started_cv.notify_one(); + std::unique_lock lk(barrier_mtx); + barrier_cv.wait(lk, [&] { return release; }); + })); + { + std::unique_lock lk(started_mtx); + started_cv.wait(lk, [&] { return started.load() >= 1; }); + } + + // Fill the 2-slot queue + for (int i = 0; i < 2; ++i) { + pool.try_submit(espp::ThreadPool::Job([&]() { + std::unique_lock lk(barrier_mtx); + barrier_cv.wait(lk, [&] { return release; }); + })); + } + + // Queue now provably full — every further try_submit must be rejected + int rejected = 0; + for (int i = 0; i < 3; ++i) { + if (!pool.try_submit(espp::ThreadPool::Job([] {}))) { + ++rejected; + } + } + check(rejected == 3, "3 try_submit calls rejected when queue full"); + check(pool.stats().rejected == 3, "stats.rejected == 3"); + + { + std::lock_guard lk(barrier_mtx); + release = true; + } + barrier_cv.notify_all(); + pool.stop(); + } + + // --------------------------------------------------------------------------- + // 4. submit() after stop() — rejected + // --------------------------------------------------------------------------- + logger.info("--- submit: rejected after stop() ---"); + { + espp::ThreadPool pool({ + .worker_count = 1, + .auto_start = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + pool.stop(); + check(!pool.submit(espp::ThreadPool::Job([] {})), "submit after stop() returns false"); + check(pool.stats().rejected == 1, "stats.rejected == 1"); + } + + // --------------------------------------------------------------------------- + // 5. Concurrent submit + try_submit from multiple producer threads + // --------------------------------------------------------------------------- + logger.info("--- concurrent: multi-thread submit and try_submit ---"); + { + std::mutex mtx; + std::condition_variable cv; + constexpr int num_threads = 4; + constexpr int jobs_per_thread = 10; + constexpr int total = num_threads * jobs_per_thread; + std::atomic done{0}; + + espp::ThreadPool pool({ + .worker_count = 3, + .auto_start = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + std::vector producers; + producers.reserve(num_threads); + for (int t = 0; t < num_threads; ++t) { + producers.emplace_back([&]() { + for (int i = 0; i < jobs_per_thread; ++i) { + pool.submit(espp::ThreadPool::Job([&]() { + std::this_thread::sleep_for(5ms); + ++done; + cv.notify_one(); + })); + } + }); + } + for (auto &p : producers) + p.join(); + + { + std::unique_lock lk(mtx); + cv.wait(lk, [&] { return done.load() >= total; }); + } + + auto s = pool.stats(); + logger.info(" stats: submitted={} executed={} rejected={}", s.submitted, s.executed, + s.rejected); + check(s.submitted == total, "all concurrent submissions accepted"); + check(s.executed == total, "all submitted jobs executed"); + check(s.rejected == 0, "no jobs rejected (unbounded queue)"); + pool.stop(); + } + + // --------------------------------------------------------------------------- + // 6. Blocking submit when full (block_on_submit_when_full = true) + // --------------------------------------------------------------------------- + logger.info("--- submit: blocking when queue full ---"); + { + std::mutex mtx; + std::condition_variable cv; + std::atomic done{0}; + constexpr int total = 6; + + espp::ThreadPool pool({ + .worker_count = 1, + .max_queue_size = 2, + .auto_start = true, + .block_on_submit_when_full = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + int accepted = 0; + for (int i = 0; i < total; ++i) { + if (pool.submit(espp::ThreadPool::Job([&]() { + std::this_thread::sleep_for(30ms); + ++done; + cv.notify_one(); + }))) { + ++accepted; + } + } + check(accepted == total, "all jobs accepted (blocking submit)"); + + { + std::unique_lock lk(mtx); + cv.wait(lk, [&] { return done.load() >= total; }); + } + + auto s = pool.stats(); + logger.info(" stats: submitted={} executed={} rejected={}", s.submitted, s.executed, + s.rejected); + check(s.submitted == total, "submitted == total"); + check(s.executed == total, "executed == total"); + check(s.rejected == 0, "rejected == 0"); + pool.stop(); + } + + // --------------------------------------------------------------------------- + // 7. Concurrent start/stop from multiple threads + // --------------------------------------------------------------------------- + logger.info("--- concurrent: start/stop from multiple threads ---"); + { + espp::ThreadPool pool({ + .worker_count = 2, + .auto_start = false, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + constexpr int num_threads = 4; + constexpr int iterations = 10; + std::vector threads; + threads.reserve(num_threads); + for (int t = 0; t < num_threads; ++t) { + threads.emplace_back([&pool, t]() { + for (int i = 0; i < iterations; ++i) { + if ((t + i) % 2 == 0) + pool.start(); + else + pool.stop(); + } + }); + } + for (auto &t : threads) + t.join(); + + pool.stop(); + check(!pool.is_running(), "pool reaches a clean stopped state"); + auto s = pool.stats(); + logger.info(" stats: submitted={} executed={} rejected={}", s.submitted, s.executed, + s.rejected); + check(s.submitted == 0 && s.executed == 0 && s.rejected == 0, + "stats all zero (no jobs submitted)"); + } + + // --------------------------------------------------------------------------- + // 8. Chained pools: a job in pool_a submits work to pool_b + // --------------------------------------------------------------------------- + logger.info("--- chained: job in pool_a submits to pool_b ---"); + { + std::mutex mtx; + std::condition_variable cv; + constexpr int num_a_jobs = 5; + constexpr int b_jobs_per_a = 2; + constexpr int total_b = num_a_jobs * b_jobs_per_a; + std::atomic done_b{0}; + + espp::ThreadPool pool_b({ + .worker_count = 2, + .auto_start = true, + .worker_task_config = + {.name = "pool_b_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + espp::ThreadPool pool_a({ + .worker_count = 2, + .auto_start = true, + .worker_task_config = + {.name = "pool_a_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + for (int i = 0; i < num_a_jobs; ++i) { + pool_a.submit(espp::ThreadPool::Job([&pool_b, &done_b, &cv]() { + for (int j = 0; j < b_jobs_per_a; ++j) { + pool_b.submit(espp::ThreadPool::Job([&done_b, &cv]() { + std::this_thread::sleep_for(20ms); + ++done_b; + cv.notify_one(); + })); + } + })); + } + + { + std::unique_lock lk(mtx); + cv.wait(lk, [&] { return done_b.load() >= total_b; }); + } + + auto sa = pool_a.stats(); + auto sb = pool_b.stats(); + logger.info(" pool_a stats: submitted={} executed={} rejected={}", sa.submitted, sa.executed, + sa.rejected); + logger.info(" pool_b stats: submitted={} executed={} rejected={}", sb.submitted, sb.executed, + sb.rejected); + check(sa.executed == num_a_jobs, "pool_a executes all A jobs"); + check(sb.executed == total_b, "pool_b executes all chained B jobs"); + check(sb.rejected == 0, "pool_b rejects no jobs"); + pool_a.stop(); + pool_b.stop(); + } + + // --------------------------------------------------------------------------- + // 9. Self-submit: a job submits another job back to the same pool + // --------------------------------------------------------------------------- + logger.info("--- self-submit: job submits to its own pool ---"); + { + std::mutex mtx; + std::condition_variable cv; + constexpr int num_initial = 4; + constexpr int total = num_initial * 2; // each initial job spawns one follow-up + std::atomic done{0}; + + espp::ThreadPool pool({ + .worker_count = 2, + .auto_start = true, + .worker_task_config = + {.name = "tp_worker", .stack_size_bytes = 4096, .priority = 5, .core_id = -1}, + }); + + for (int i = 0; i < num_initial; ++i) { + pool.submit(espp::ThreadPool::Job([&pool, &done, &cv]() { + ++done; + cv.notify_one(); + pool.submit(espp::ThreadPool::Job([&done, &cv]() { + std::this_thread::sleep_for(10ms); + ++done; + cv.notify_one(); + })); + })); + } + + { + std::unique_lock lk(mtx); + cv.wait(lk, [&] { return done.load() >= total; }); + } + + auto s = pool.stats(); + logger.info(" stats: submitted={} executed={} rejected={}", s.submitted, s.executed, + s.rejected); + check(s.submitted == total, "all initial + follow-up jobs submitted"); + check(s.executed == total, "all initial + follow-up jobs executed"); + check(s.rejected == 0, "no jobs rejected"); + pool.stop(); + } + + // --------------------------------------------------------------------------- + // Summary + // --------------------------------------------------------------------------- + logger.info("==================== Results ===================="); + logger.info("{}/{} checks passed", total_passed, total_tests); + if (total_passed == total_tests) { + logger.info("All checks passed!"); + } else { + logger.error("{} check(s) FAILED", total_tests - total_passed); + } + + return (total_passed == total_tests) ? 0 : 1; +} diff --git a/python/thread_pool.py b/python/thread_pool.py new file mode 100644 index 000000000..d7f0c5c79 --- /dev/null +++ b/python/thread_pool.py @@ -0,0 +1,534 @@ +"""ThreadPool Python test. + +Mirrors the 9 test sections of the C++ thread_pool_example.cpp, exercising +the full espp.ThreadPool Python binding API. + +Exit code 0 on full pass, 1 on any failure. +""" + +import sys +import threading +import time +from typing import List, Tuple + +import espp + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +results: List[Tuple[str, bool]] = [] + + +def check(test: str, condition: bool, desc: str) -> bool: + if condition: + print(f" PASS [{test}]: {desc}") + else: + print(f" FAIL [{test}]: {desc}") + return condition + + +def wait_for_jobs(event: threading.Event, counter, expected: int, timeout: float = 5.0) -> None: + event.wait(timeout=timeout) + + +# --------------------------------------------------------------------------- +# 1. Lifecycle: start / stop / is_running / worker_count +# --------------------------------------------------------------------------- +name = "lifecycle: start/stop/is_running/worker_count" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=3, auto_start=False)) +passed &= check(name, not pool.is_running(), "pool should not be running before start()") +passed &= check(name, pool.worker_count() == 3, "worker_count() should be 3") +passed &= check(name, pool.start(), "start() should return True on first call") +passed &= check(name, pool.is_running(), "pool should be running after start()") +passed &= check(name, pool.start(), "start() should return True when already running (no-op)") +passed &= check(name, pool.is_running(), "pool should still be running after duplicate start()") +pool.stop() +passed &= check(name, not pool.is_running(), "pool should not be running after stop()") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 2. submit() + queue_size() + stats() +# --------------------------------------------------------------------------- +name = "submit: normal dispatch + queue_size + stats" +print(f"--- {name} ---") +passed = True + +TOTAL_JOBS = 8 +counter = [0] +counter_lock = threading.Lock() +all_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +accepted = 0 +for i in range(TOTAL_JOBS): + def _job(i=i): + time.sleep(0.05) + with counter_lock: + counter[0] += 1 + if counter[0] >= TOTAL_JOBS: + all_done.set() + if pool.submit(_job): + accepted += 1 + +passed &= check(name, accepted == TOTAL_JOBS, "all jobs should be accepted (unbounded queue)") +all_done.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") +passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") +passed &= check(name, s.rejected == 0, "rejected count should be 0") +passed &= check(name, pool.queue_size() == 0, "queue should be empty after all jobs finish") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 3. try_submit() — deterministic rejection when queue is full +# --------------------------------------------------------------------------- +name = "try_submit: rejection when queue full" +print(f"--- {name} ---") +passed = True + +# 1 worker, capacity 2: 1 executing + 2 queued = 3 slots total. +# Gate workers with a barrier so the queue stays full during the rejection check. +# Track ALL 3 fill-job completions before calling pool.stop() to guarantee +# no worker thread is executing Python code when stop() joins the thread. +barrier = threading.Event() +first_started = threading.Event() +all_fill_done = threading.Event() +fill_done_count = [0] +fill_done_lock = threading.Lock() +TOTAL_FILL = 3 + +def _make_fill_job(signal_start: bool = False): + def _job(): + if signal_start: + first_started.set() + barrier.wait() + with fill_done_lock: + fill_done_count[0] += 1 + if fill_done_count[0] >= TOTAL_FILL: + all_fill_done.set() + return _job + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1, max_queue_size=2)) + +# Submit first job and wait until it is executing (off the queue) +pool.try_submit(_make_fill_job(signal_start=True)) +first_started.wait(timeout=2.0) + +# Fill the 2-slot queue +fill_accepted = 1 # first job counted above +for _ in range(2): + if pool.try_submit(_make_fill_job()): + fill_accepted += 1 + +passed &= check(name, fill_accepted == 3, "first 3 try_submit calls should be accepted") + +# Queue is now provably full +rejected_count = sum(1 for _ in range(3) if not pool.try_submit(lambda: None)) +passed &= check(name, rejected_count == 3, "try_submit when full should return False") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.rejected == 3, "stats.rejected should be 3") + +barrier.set() +# Wait for ALL 3 fill jobs to finish their Python callbacks before calling +# stop(). Waiting only for the first job is not enough — after it finishes, +# the worker immediately picks up the queued jobs and needs the GIL to execute +# them. stop() would deadlock if called while those callbacks are running. +all_fill_done.wait(timeout=5.0) +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 4. Queue backpressure: all jobs accepted despite bounded queue +# --------------------------------------------------------------------------- +# NOTE: pool.submit() with block_on_submit_when_full=True cannot be called +# from a Python thread: it blocks in C++ while holding the GIL, preventing +# worker threads from executing their Python callbacks (they also need the +# GIL), causing a deadlock. Instead, we use try_submit() with a sleep-retry +# loop which releases the GIL on each iteration and achieves the same +# backpressure semantics safely. +name = "submit: queue backpressure (try_submit retry)" +print(f"--- {name} ---") +passed = True + +TOTAL_JOBS = 6 +counter2 = [0] +counter2_lock = threading.Lock() +all_done2 = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config( + worker_count=1, + max_queue_size=2, + block_on_submit_when_full=False, +)) + +accepted2 = 0 +for i in range(TOTAL_JOBS): + def _job2(i=i): + time.sleep(0.03) + with counter2_lock: + counter2[0] += 1 + if counter2[0] >= TOTAL_JOBS: + all_done2.set() + # Retry until accepted, releasing the GIL on each sleep so workers can run + while not pool.try_submit(_job2): + time.sleep(0.001) + accepted2 += 1 + +passed &= check(name, accepted2 == TOTAL_JOBS, "all jobs should be accepted (try_submit retry)") +all_done2.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") +passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") +passed &= check(name, s.rejected > 0, "rejected reflects try_submit retries (expected > 0)") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 5. submit() after stop() — rejected via is_running() guard +# --------------------------------------------------------------------------- +name = "submit: rejected after stop()" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1)) +pool.stop() +accepted_after_stop = pool.submit(lambda: None) +passed &= check(name, not accepted_after_stop, "submit() after stop() should return False") +passed &= check(name, pool.stats().submitted == 0, "submitted count should be 0") +passed &= check(name, pool.stats().rejected == 1, "rejected count should be 1") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 6. Concurrent start/stop from multiple threads +# --------------------------------------------------------------------------- +name = "concurrent: start/stop from multiple threads" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2, auto_start=False)) + +NUM_THREADS = 4 +ITERATIONS = 10 + +def _lifecycle_thread(t: int) -> None: + for i in range(ITERATIONS): + if (t + i) % 2 == 0: + pool.start() + else: + pool.stop() + +threads = [threading.Thread(target=_lifecycle_thread, args=(t,)) for t in range(NUM_THREADS)] +for t in threads: + t.start() +for t in threads: + t.join() + +pool.stop() +passed &= check(name, not pool.is_running(), "pool should reach a clean stopped state") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == 0 and s.executed == 0 and s.rejected == 0, + "stats should all be zero (no jobs submitted)") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 7. Concurrent submit and try_submit from multiple producer threads +# --------------------------------------------------------------------------- +name = "concurrent: multi-thread submit and try_submit" +print(f"--- {name} ---") +passed = True + +NUM_SUBMIT_THREADS = 3 +NUM_TRY_SUBMIT_THREADS = 2 +JOBS_PER_THREAD = 10 +TOTAL = (NUM_SUBMIT_THREADS + NUM_TRY_SUBMIT_THREADS) * JOBS_PER_THREAD + +c7 = [0] +c7_lock = threading.Lock() +c7_done = threading.Event() +total_accepted7 = [0] +ta7_lock = threading.Lock() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=4)) + +def _submit_producer(): + for _ in range(JOBS_PER_THREAD): + def _w(): + time.sleep(0.01) + with c7_lock: + c7[0] += 1 + if c7[0] >= total_accepted7[0]: + c7_done.set() + if pool.submit(_w): + with ta7_lock: + total_accepted7[0] += 1 + +def _try_submit_producer(): + for _ in range(JOBS_PER_THREAD): + def _w(): + time.sleep(0.01) + with c7_lock: + c7[0] += 1 + if c7[0] >= total_accepted7[0]: + c7_done.set() + if pool.try_submit(_w): + with ta7_lock: + total_accepted7[0] += 1 + +producers = ( + [threading.Thread(target=_submit_producer) for _ in range(NUM_SUBMIT_THREADS)] + + [threading.Thread(target=_try_submit_producer) for _ in range(NUM_TRY_SUBMIT_THREADS)] +) +for p in producers: + p.start() +for p in producers: + p.join() + +c7_done.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted + s.rejected == TOTAL, + "submitted + rejected should equal total attempted") +passed &= check(name, s.executed == s.submitted, + "all accepted jobs should be executed (unbounded queue)") +passed &= check(name, s.rejected == 0, "unbounded queue should not reject any jobs") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 8. Chained pools: a job in pool_a submits work to pool_b +# --------------------------------------------------------------------------- +name = "chained: job in pool_a submits to pool_b" +print(f"--- {name} ---") +passed = True + +NUM_A_JOBS = 5 +B_JOBS_PER_A = 2 +TOTAL_B = NUM_A_JOBS * B_JOBS_PER_A + +c8 = [0] +c8_lock = threading.Lock() +c8_done = threading.Event() + +pool_b = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) +pool_a = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +for _ in range(NUM_A_JOBS): + def _a_job(): + for _ in range(B_JOBS_PER_A): + def _b_job(): + time.sleep(0.02) + with c8_lock: + c8[0] += 1 + if c8[0] >= TOTAL_B: + c8_done.set() + pool_b.submit(_b_job) + pool_a.submit(_a_job) + +c8_done.wait(timeout=10.0) +sa = pool_a.stats() +sb = pool_b.stats() +print(f" pool_a stats: {sa.submitted} submitted, {sa.executed} executed, {sa.rejected} rejected") +print(f" pool_b stats: {sb.submitted} submitted, {sb.executed} executed, {sb.rejected} rejected") +passed &= check(name, sa.executed == NUM_A_JOBS, "pool_a should execute all A jobs") +passed &= check(name, sb.executed == TOTAL_B, "pool_b should execute all chained B jobs") +passed &= check(name, sb.rejected == 0, "pool_b should not reject any jobs") +pool_a.stop() +pool_b.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 9. Self-submit: a job submits another job back to the same pool +# --------------------------------------------------------------------------- +name = "self-submit: job submits to its own pool" +print(f"--- {name} ---") +passed = True + +NUM_INITIAL = 4 +TOTAL_EXEC = NUM_INITIAL * 2 # each initial job spawns one follow-up + +c9 = [0] +c9_lock = threading.Lock() +c9_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +for _ in range(NUM_INITIAL): + def _initial(): + with c9_lock: + c9[0] += 1 + if c9[0] >= TOTAL_EXEC: + c9_done.set() + def _followup(): + time.sleep(0.01) + with c9_lock: + c9[0] += 1 + if c9[0] >= TOTAL_EXEC: + c9_done.set() + pool.submit(_followup) + pool.submit(_initial) + +c9_done.wait(timeout=5.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_EXEC, + "all initial + follow-up jobs should be submitted") +passed &= check(name, s.executed == TOTAL_EXEC, + "all initial + follow-up jobs should be executed") +passed &= check(name, s.rejected == 0, "no jobs should be rejected") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +print("==================== Results ====================") +total_passed = sum(1 for _, p in results if p) +for r_name, r_passed in results: + tag = "PASS" if r_passed else "FAIL" + print(f" {tag} {r_name}") +print("=================================================") +print(f"{total_passed}/{len(results)} tests passed") +if total_passed == len(results): + print("All tests passed!") + sys.exit(0) +else: + print(f"{len(results) - total_passed} test(s) FAILED") + sys.exit(1) + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2, auto_start=False)) +check(not pool.is_running(), "not running before start()") +check(pool.worker_count() == 2, "worker_count() == 2") +check(pool.start(), "start() returns True") +check(pool.is_running(), "is_running() after start()") +check(pool.start(), "duplicate start() is no-op, returns True") +pool.stop() +check(not pool.is_running(), "not running after stop()") + +# --------------------------------------------------------------------------- +# 2. submit() + queue_size() + stats() +# --------------------------------------------------------------------------- +print("--- submit + stats ---") +N = 6 +counter = [0] +counter_lock = threading.Lock() +all_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +for i in range(N): + def _job(i=i): + time.sleep(0.02) + with counter_lock: + counter[0] += 1 + if counter[0] >= N: + all_done.set() + pool.submit(_job) + +all_done.wait(timeout=5.0) +s = pool.stats() +print(f" stats: submitted={s.submitted} executed={s.executed} rejected={s.rejected}") +check(s.submitted == N, f"submitted == {N}") +check(s.executed == N, f"executed == {N}") +check(s.rejected == 0, "rejected == 0") +check(pool.queue_size() == 0, "queue empty after all jobs finish") +pool.stop() + +# --------------------------------------------------------------------------- +# 3. try_submit() — deterministic rejection when queue is full +# --------------------------------------------------------------------------- +print("--- try_submit bounded queue ---") +barrier = threading.Event() +first_started = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1, max_queue_size=2)) + +# Submit first job and wait until it is executing (off the queue) +def _blocking_job(): + first_started.set() + barrier.wait() + +pool.try_submit(_blocking_job) +first_started.wait(timeout=2.0) + +# Fill the 2-slot queue +pool.try_submit(lambda: barrier.wait()) +pool.try_submit(lambda: barrier.wait()) + +# Queue is now provably full — every further try_submit must be rejected +rejected = sum(1 for _ in range(3) if not pool.try_submit(lambda: None)) +check(rejected == 3, "3 try_submit calls rejected when queue full") +check(pool.stats().rejected == 3, "stats.rejected == 3") + +barrier.set() +pool.stop() + +# --------------------------------------------------------------------------- +# 4. submit() after stop() — rejected +# --------------------------------------------------------------------------- +print("--- submit after stop ---") +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1)) +pool.stop() +check(not pool.submit(lambda: None), "submit after stop() returns False") +check(pool.stats().rejected == 1, "stats.rejected == 1") + +# --------------------------------------------------------------------------- +# 5. Concurrent submit from multiple threads +# --------------------------------------------------------------------------- +print("--- concurrent submit ---") +NUM_THREADS = 4 +JOBS_PER_THREAD = 10 +TOTAL_JOBS = NUM_THREADS * JOBS_PER_THREAD + +c_counter = [0] +c_lock = threading.Lock() +c_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=3)) + +def _producer(): + for _ in range(JOBS_PER_THREAD): + def _work(): + time.sleep(0.005) + with c_lock: + c_counter[0] += 1 + if c_counter[0] >= TOTAL_JOBS: + c_done.set() + pool.submit(_work) + +threads = [threading.Thread(target=_producer) for _ in range(NUM_THREADS)] +for t in threads: + t.start() +for t in threads: + t.join() + +c_done.wait(timeout=10.0) +s = pool.stats() +print(f" stats: submitted={s.submitted} executed={s.executed} rejected={s.rejected}") +check(s.submitted == TOTAL_JOBS, "all concurrent submissions accepted") +check(s.executed == TOTAL_JOBS, "all submitted jobs executed") +check(s.rejected == 0, "no jobs rejected (unbounded queue)") +pool.stop() + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +print("=" * 52) +print(f"{passed}/{total} checks passed") +if passed == total: + print("All checks passed!") + sys.exit(0) +else: + print(f"{total - passed} check(s) FAILED") + sys.exit(1) From affb5ed6859bb64a1d4f9c9290c7cbdf3cefffea Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 13:38:38 -0500 Subject: [PATCH 4/9] remove unused testing code; --- python/thread_pool.py | 126 ------------------------------------------ 1 file changed, 126 deletions(-) diff --git a/python/thread_pool.py b/python/thread_pool.py index d7f0c5c79..483735438 100644 --- a/python/thread_pool.py +++ b/python/thread_pool.py @@ -406,129 +406,3 @@ def _followup(): else: print(f"{len(results) - total_passed} test(s) FAILED") sys.exit(1) - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2, auto_start=False)) -check(not pool.is_running(), "not running before start()") -check(pool.worker_count() == 2, "worker_count() == 2") -check(pool.start(), "start() returns True") -check(pool.is_running(), "is_running() after start()") -check(pool.start(), "duplicate start() is no-op, returns True") -pool.stop() -check(not pool.is_running(), "not running after stop()") - -# --------------------------------------------------------------------------- -# 2. submit() + queue_size() + stats() -# --------------------------------------------------------------------------- -print("--- submit + stats ---") -N = 6 -counter = [0] -counter_lock = threading.Lock() -all_done = threading.Event() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) - -for i in range(N): - def _job(i=i): - time.sleep(0.02) - with counter_lock: - counter[0] += 1 - if counter[0] >= N: - all_done.set() - pool.submit(_job) - -all_done.wait(timeout=5.0) -s = pool.stats() -print(f" stats: submitted={s.submitted} executed={s.executed} rejected={s.rejected}") -check(s.submitted == N, f"submitted == {N}") -check(s.executed == N, f"executed == {N}") -check(s.rejected == 0, "rejected == 0") -check(pool.queue_size() == 0, "queue empty after all jobs finish") -pool.stop() - -# --------------------------------------------------------------------------- -# 3. try_submit() — deterministic rejection when queue is full -# --------------------------------------------------------------------------- -print("--- try_submit bounded queue ---") -barrier = threading.Event() -first_started = threading.Event() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1, max_queue_size=2)) - -# Submit first job and wait until it is executing (off the queue) -def _blocking_job(): - first_started.set() - barrier.wait() - -pool.try_submit(_blocking_job) -first_started.wait(timeout=2.0) - -# Fill the 2-slot queue -pool.try_submit(lambda: barrier.wait()) -pool.try_submit(lambda: barrier.wait()) - -# Queue is now provably full — every further try_submit must be rejected -rejected = sum(1 for _ in range(3) if not pool.try_submit(lambda: None)) -check(rejected == 3, "3 try_submit calls rejected when queue full") -check(pool.stats().rejected == 3, "stats.rejected == 3") - -barrier.set() -pool.stop() - -# --------------------------------------------------------------------------- -# 4. submit() after stop() — rejected -# --------------------------------------------------------------------------- -print("--- submit after stop ---") -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1)) -pool.stop() -check(not pool.submit(lambda: None), "submit after stop() returns False") -check(pool.stats().rejected == 1, "stats.rejected == 1") - -# --------------------------------------------------------------------------- -# 5. Concurrent submit from multiple threads -# --------------------------------------------------------------------------- -print("--- concurrent submit ---") -NUM_THREADS = 4 -JOBS_PER_THREAD = 10 -TOTAL_JOBS = NUM_THREADS * JOBS_PER_THREAD - -c_counter = [0] -c_lock = threading.Lock() -c_done = threading.Event() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=3)) - -def _producer(): - for _ in range(JOBS_PER_THREAD): - def _work(): - time.sleep(0.005) - with c_lock: - c_counter[0] += 1 - if c_counter[0] >= TOTAL_JOBS: - c_done.set() - pool.submit(_work) - -threads = [threading.Thread(target=_producer) for _ in range(NUM_THREADS)] -for t in threads: - t.start() -for t in threads: - t.join() - -c_done.wait(timeout=10.0) -s = pool.stats() -print(f" stats: submitted={s.submitted} executed={s.executed} rejected={s.rejected}") -check(s.submitted == TOTAL_JOBS, "all concurrent submissions accepted") -check(s.executed == TOTAL_JOBS, "all submitted jobs executed") -check(s.rejected == 0, "no jobs rejected (unbounded queue)") -pool.stop() - -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- -print("=" * 52) -print(f"{passed}/{total} checks passed") -if passed == total: - print("All checks passed!") - sys.exit(0) -else: - print(f"{total - passed} check(s) FAILED") - sys.exit(1) From be067337a37922244ee90523693583d3c792ef57 Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 14:31:06 -0500 Subject: [PATCH 5/9] add start stop to GIL release methods --- lib/autogenerate_bindings.py | 1 + lib/python_bindings/espp/__init__.pyi | 6 ++++-- lib/python_bindings/pybind_espp.cpp | 9 +++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index 4775d7f9f..dff51a392 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -247,6 +247,7 @@ def _fix_rtsp_qualifications(code: str) -> str: ], "espp::RtspServer": ["start", "stop", "send_frame"], "espp::RtspSession": ["send_rtp_packet", "send_rtcp_packet"], + "espp::ThreadPool": ["start", "stop"], } _GIL_GUARD = "py::call_guard()" diff --git a/lib/python_bindings/espp/__init__.pyi b/lib/python_bindings/espp/__init__.pyi index d236cbb5b..f139f4923 100644 --- a/lib/python_bindings/espp/__init__.pyi +++ b/lib/python_bindings/espp/__init__.pyi @@ -3817,7 +3817,8 @@ class ThreadPool: """/ @brief Snapshot of pool activity counters.""" submitted: std.int = 0 #/< Total jobs accepted into the queue. executed: std.int = 0 #/< Total jobs successfully executed. - rejected: std.int = 0 #/< Total jobs rejected (invalid job, stopped/stopping, or queue full) or dropped (due to stop, the enqueued jobs were dropped). + rejected: std.int = 0 #/< Total jobs rejected (invalid job, stopped/stopping, or queue + #/< full) or dropped (due to stop, the enqueued jobs were dropped). def __init__( self, submitted: std.int = 0, @@ -3833,7 +3834,8 @@ class ThreadPool: max_queue_size: std.int = 0 #/< Maximum pending jobs (0 = unbounded). auto_start: bool = True #/< Start workers immediately on construction. block_on_submit_when_full: bool = False #/< If True, submit() blocks when the queue is full instead of rejecting. - worker_task_config: Task.BaseConfig = Task.BaseConfig( ///< Base configuration applied to every worker task. + worker_task_config: Task.BaseConfig = Task.BaseConfig( + ///< Base configuration applied to every worker task. .name = "thread_pool_worker", .stack_size_bytes = 4096, .priority = 5, diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index 7e2ec92c9..4b42ded08 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -2394,8 +2394,7 @@ void py_init_module_espp(py::module &m) { .def_readwrite("executed", &espp::ThreadPool::Stats::executed, "/< Total jobs successfully executed.") .def_readwrite("rejected", &espp::ThreadPool::Stats::rejected, - "/< Total jobs rejected (invalid job, stopped/stopping, or queue full) " - "or dropped (due to stop, the enqueued jobs were dropped)."); + "/< Total jobs rejected (invalid job, stopped/stopping, or queue"); auto pyClassThreadPool_ClassConfig = py::class_( pyClassThreadPool, "Config", py::dynamic_attr(), @@ -2450,10 +2449,12 @@ void py_init_module_espp(py::module &m) { "/ @brief Start all worker threads.\n/ @return True if all workers were successfully " "started, False otherwise.\n/ @note No-op if the pool is already running and return " "True immediately.\n/ @note If any workers could not be started, the pool will roll " - "back to the stopped state.") + "back to the stopped state.", + py::call_guard()) .def("stop", &espp::ThreadPool::stop, "/ @brief Stop all worker threads and reject further submissions.\n/ @note Blocks until " - "every worker has exited; queued jobs may not be executed.") + "every worker has exited; queued jobs may not be executed.", + py::call_guard()) .def("is_running", &espp::ThreadPool::is_running, "/ @brief Query whether the pool is currently running.\n/ @return True if workers are " "active, False otherwise.") From f935acd9c539152fcfb2ee17690009f195994d4d Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 15:19:12 -0500 Subject: [PATCH 6/9] fix per AI review --- lib/autogenerate_bindings.py | 2 +- lib/python_bindings/pybind_espp.cpp | 3 ++- python/thread_pool.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index dff51a392..4b063b682 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -247,7 +247,7 @@ def _fix_rtsp_qualifications(code: str) -> str: ], "espp::RtspServer": ["start", "stop", "send_frame"], "espp::RtspSession": ["send_rtp_packet", "send_rtcp_packet"], - "espp::ThreadPool": ["start", "stop"], + "espp::ThreadPool": ["start", "stop", "submit"], } _GIL_GUARD = "py::call_guard()" diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index 4b42ded08..de23525e2 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -2463,7 +2463,8 @@ void py_init_module_espp(py::module &m) { "Config::block_on_submit_when_full is True and the queue has\n/ reached its capacity " "limit. Otherwise behaves identically to try_submit().\n/ @param job Callable to " "enqueue; moved into the queue on acceptance.\n/ @return True if the job was accepted, " - "False if it was rejected.") + "False if it was rejected.", + py::call_guard()) .def("try_submit", &espp::ThreadPool::try_submit, py::arg("job"), "/ @brief Attempt to submit a job without blocking.\n/\n/ Returns immediately with " "False when the queue is full.\n/ @param job Callable to enqueue; moved into the queue " diff --git a/python/thread_pool.py b/python/thread_pool.py index 483735438..f6569dbb0 100644 --- a/python/thread_pool.py +++ b/python/thread_pool.py @@ -268,7 +268,7 @@ def _w(): time.sleep(0.01) with c7_lock: c7[0] += 1 - if c7[0] >= total_accepted7[0]: + if c7[0] >= TOTAL: c7_done.set() if pool.submit(_w): with ta7_lock: @@ -280,7 +280,7 @@ def _w(): time.sleep(0.01) with c7_lock: c7[0] += 1 - if c7[0] >= total_accepted7[0]: + if c7[0] >= TOTAL: c7_done.set() if pool.try_submit(_w): with ta7_lock: From bee6701b31b8c489a280aff279fbd1b615d99759 Mon Sep 17 00:00:00 2001 From: guo-max Date: Mon, 27 Jul 2026 15:48:37 -0500 Subject: [PATCH 7/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- python/thread_pool.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/python/thread_pool.py b/python/thread_pool.py index f6569dbb0..ec8fe89a7 100644 --- a/python/thread_pool.py +++ b/python/thread_pool.py @@ -148,11 +148,9 @@ def _job(): # --------------------------------------------------------------------------- # 4. Queue backpressure: all jobs accepted despite bounded queue # --------------------------------------------------------------------------- -# NOTE: pool.submit() with block_on_submit_when_full=True cannot be called -# from a Python thread: it blocks in C++ while holding the GIL, preventing -# worker threads from executing their Python callbacks (they also need the -# GIL), causing a deadlock. Instead, we use try_submit() with a sleep-retry -# loop which releases the GIL on each iteration and achieves the same +# NOTE: ThreadPool.submit() is bound with a GIL-release guard, so blocking submit does not +# inherently deadlock Python worker callbacks due to the GIL. This section uses a try_submit() +# + sleep-retry loop to apply backpressure without blocking the calling thread. # backpressure semantics safely. name = "submit: queue backpressure (try_submit retry)" print(f"--- {name} ---") From 3c2f01b6c923b76b3b741c8e716b3b6174cf5552 Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 15:58:33 -0500 Subject: [PATCH 8/9] add thread_pool example and rename old one to test --- python/thread_pool.py | 413 +++---------------------------------- python/thread_pool_test.py | 406 ++++++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+), 386 deletions(-) create mode 100644 python/thread_pool_test.py diff --git a/python/thread_pool.py b/python/thread_pool.py index ec8fe89a7..071fbe1d7 100644 --- a/python/thread_pool.py +++ b/python/thread_pool.py @@ -1,406 +1,47 @@ -"""ThreadPool Python test. - -Mirrors the 9 test sections of the C++ thread_pool_example.cpp, exercising -the full espp.ThreadPool Python binding API. - -Exit code 0 on full pass, 1 on any failure. -""" - import sys -import threading import time -from typing import List, Tuple +import threading import espp # --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -results: List[Tuple[str, bool]] = [] - - -def check(test: str, condition: bool, desc: str) -> bool: - if condition: - print(f" PASS [{test}]: {desc}") - else: - print(f" FAIL [{test}]: {desc}") - return condition - - -def wait_for_jobs(event: threading.Event, counter, expected: int, timeout: float = 5.0) -> None: - event.wait(timeout=timeout) - - -# --------------------------------------------------------------------------- -# 1. Lifecycle: start / stop / is_running / worker_count -# --------------------------------------------------------------------------- -name = "lifecycle: start/stop/is_running/worker_count" -print(f"--- {name} ---") -passed = True - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=3, auto_start=False)) -passed &= check(name, not pool.is_running(), "pool should not be running before start()") -passed &= check(name, pool.worker_count() == 3, "worker_count() should be 3") -passed &= check(name, pool.start(), "start() should return True on first call") -passed &= check(name, pool.is_running(), "pool should be running after start()") -passed &= check(name, pool.start(), "start() should return True when already running (no-op)") -passed &= check(name, pool.is_running(), "pool should still be running after duplicate start()") -pool.stop() -passed &= check(name, not pool.is_running(), "pool should not be running after stop()") -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 2. submit() + queue_size() + stats() +# Basic usage: submit jobs, wait for completion, read stats # --------------------------------------------------------------------------- -name = "submit: normal dispatch + queue_size + stats" -print(f"--- {name} ---") -passed = True +start = time.time() +def elapsed(): + return time.time() - start -TOTAL_JOBS = 8 -counter = [0] counter_lock = threading.Lock() -all_done = threading.Event() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) - -accepted = 0 -for i in range(TOTAL_JOBS): - def _job(i=i): - time.sleep(0.05) - with counter_lock: - counter[0] += 1 - if counter[0] >= TOTAL_JOBS: - all_done.set() - if pool.submit(_job): - accepted += 1 - -passed &= check(name, accepted == TOTAL_JOBS, "all jobs should be accepted (unbounded queue)") -all_done.wait(timeout=10.0) -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") -passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") -passed &= check(name, s.rejected == 0, "rejected count should be 0") -passed &= check(name, pool.queue_size() == 0, "queue should be empty after all jobs finish") -pool.stop() -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 3. try_submit() — deterministic rejection when queue is full -# --------------------------------------------------------------------------- -name = "try_submit: rejection when queue full" -print(f"--- {name} ---") -passed = True - -# 1 worker, capacity 2: 1 executing + 2 queued = 3 slots total. -# Gate workers with a barrier so the queue stays full during the rejection check. -# Track ALL 3 fill-job completions before calling pool.stop() to guarantee -# no worker thread is executing Python code when stop() joins the thread. -barrier = threading.Event() -first_started = threading.Event() -all_fill_done = threading.Event() -fill_done_count = [0] -fill_done_lock = threading.Lock() -TOTAL_FILL = 3 - -def _make_fill_job(signal_start: bool = False): - def _job(): - if signal_start: - first_started.set() - barrier.wait() - with fill_done_lock: - fill_done_count[0] += 1 - if fill_done_count[0] >= TOTAL_FILL: - all_fill_done.set() - return _job - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1, max_queue_size=2)) - -# Submit first job and wait until it is executing (off the queue) -pool.try_submit(_make_fill_job(signal_start=True)) -first_started.wait(timeout=2.0) - -# Fill the 2-slot queue -fill_accepted = 1 # first job counted above -for _ in range(2): - if pool.try_submit(_make_fill_job()): - fill_accepted += 1 - -passed &= check(name, fill_accepted == 3, "first 3 try_submit calls should be accepted") - -# Queue is now provably full -rejected_count = sum(1 for _ in range(3) if not pool.try_submit(lambda: None)) -passed &= check(name, rejected_count == 3, "try_submit when full should return False") -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.rejected == 3, "stats.rejected should be 3") - -barrier.set() -# Wait for ALL 3 fill jobs to finish their Python callbacks before calling -# stop(). Waiting only for the first job is not enough — after it finishes, -# the worker immediately picks up the queued jobs and needs the GIL to execute -# them. stop() would deadlock if called while those callbacks are running. -all_fill_done.wait(timeout=5.0) -pool.stop() -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 4. Queue backpressure: all jobs accepted despite bounded queue -# --------------------------------------------------------------------------- -# NOTE: ThreadPool.submit() is bound with a GIL-release guard, so blocking submit does not -# inherently deadlock Python worker callbacks due to the GIL. This section uses a try_submit() -# + sleep-retry loop to apply backpressure without blocking the calling thread. -# backpressure semantics safely. -name = "submit: queue backpressure (try_submit retry)" -print(f"--- {name} ---") -passed = True - -TOTAL_JOBS = 6 -counter2 = [0] -counter2_lock = threading.Lock() -all_done2 = threading.Event() +completed = [0] +done = threading.Event() +TOTAL_JOBS = 8 pool = espp.ThreadPool(espp.ThreadPool.Config( - worker_count=1, - max_queue_size=2, - block_on_submit_when_full=False, + worker_count=2, + max_queue_size=0, + auto_start=True, + log_level=espp.Logger.Verbosity.warn, )) -accepted2 = 0 -for i in range(TOTAL_JOBS): - def _job2(i=i): - time.sleep(0.03) - with counter2_lock: - counter2[0] += 1 - if counter2[0] >= TOTAL_JOBS: - all_done2.set() - # Retry until accepted, releasing the GIL on each sleep so workers can run - while not pool.try_submit(_job2): - time.sleep(0.001) - accepted2 += 1 +print(f"[{elapsed():.3f}] Pool started, worker_count={pool.worker_count()}, is_running={pool.is_running()}") -passed &= check(name, accepted2 == TOTAL_JOBS, "all jobs should be accepted (try_submit retry)") -all_done2.wait(timeout=10.0) -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") -passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") -passed &= check(name, s.rejected > 0, "rejected reflects try_submit retries (expected > 0)") -pool.stop() -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 5. submit() after stop() — rejected via is_running() guard -# --------------------------------------------------------------------------- -name = "submit: rejected after stop()" -print(f"--- {name} ---") -passed = True - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1)) -pool.stop() -accepted_after_stop = pool.submit(lambda: None) -passed &= check(name, not accepted_after_stop, "submit() after stop() should return False") -passed &= check(name, pool.stats().submitted == 0, "submitted count should be 0") -passed &= check(name, pool.stats().rejected == 1, "rejected count should be 1") -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 6. Concurrent start/stop from multiple threads -# --------------------------------------------------------------------------- -name = "concurrent: start/stop from multiple threads" -print(f"--- {name} ---") -passed = True - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2, auto_start=False)) - -NUM_THREADS = 4 -ITERATIONS = 10 - -def _lifecycle_thread(t: int) -> None: - for i in range(ITERATIONS): - if (t + i) % 2 == 0: - pool.start() - else: - pool.stop() - -threads = [threading.Thread(target=_lifecycle_thread, args=(t,)) for t in range(NUM_THREADS)] -for t in threads: - t.start() -for t in threads: - t.join() - -pool.stop() -passed &= check(name, not pool.is_running(), "pool should reach a clean stopped state") -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.submitted == 0 and s.executed == 0 and s.rejected == 0, - "stats should all be zero (no jobs submitted)") -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 7. Concurrent submit and try_submit from multiple producer threads -# --------------------------------------------------------------------------- -name = "concurrent: multi-thread submit and try_submit" -print(f"--- {name} ---") -passed = True - -NUM_SUBMIT_THREADS = 3 -NUM_TRY_SUBMIT_THREADS = 2 -JOBS_PER_THREAD = 10 -TOTAL = (NUM_SUBMIT_THREADS + NUM_TRY_SUBMIT_THREADS) * JOBS_PER_THREAD - -c7 = [0] -c7_lock = threading.Lock() -c7_done = threading.Event() -total_accepted7 = [0] -ta7_lock = threading.Lock() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=4)) - -def _submit_producer(): - for _ in range(JOBS_PER_THREAD): - def _w(): - time.sleep(0.01) - with c7_lock: - c7[0] += 1 - if c7[0] >= TOTAL: - c7_done.set() - if pool.submit(_w): - with ta7_lock: - total_accepted7[0] += 1 - -def _try_submit_producer(): - for _ in range(JOBS_PER_THREAD): - def _w(): - time.sleep(0.01) - with c7_lock: - c7[0] += 1 - if c7[0] >= TOTAL: - c7_done.set() - if pool.try_submit(_w): - with ta7_lock: - total_accepted7[0] += 1 +for i in range(TOTAL_JOBS): + def _job(i=i): + time.sleep(0.2) + with counter_lock: + completed[0] += 1 + print(f"[{elapsed():.3f}] Job {i} done ({completed[0]}/{TOTAL_JOBS})") + if completed[0] >= TOTAL_JOBS: + done.set() + pool.submit(_job) -producers = ( - [threading.Thread(target=_submit_producer) for _ in range(NUM_SUBMIT_THREADS)] + - [threading.Thread(target=_try_submit_producer) for _ in range(NUM_TRY_SUBMIT_THREADS)] -) -for p in producers: - p.start() -for p in producers: - p.join() +print(f"[{elapsed():.3f}] All {TOTAL_JOBS} jobs submitted, queue_size={pool.queue_size()}") -c7_done.wait(timeout=10.0) +done.wait() s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.submitted + s.rejected == TOTAL, - "submitted + rejected should equal total attempted") -passed &= check(name, s.executed == s.submitted, - "all accepted jobs should be executed (unbounded queue)") -passed &= check(name, s.rejected == 0, "unbounded queue should not reject any jobs") -pool.stop() -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 8. Chained pools: a job in pool_a submits work to pool_b -# --------------------------------------------------------------------------- -name = "chained: job in pool_a submits to pool_b" -print(f"--- {name} ---") -passed = True - -NUM_A_JOBS = 5 -B_JOBS_PER_A = 2 -TOTAL_B = NUM_A_JOBS * B_JOBS_PER_A - -c8 = [0] -c8_lock = threading.Lock() -c8_done = threading.Event() - -pool_b = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) -pool_a = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) - -for _ in range(NUM_A_JOBS): - def _a_job(): - for _ in range(B_JOBS_PER_A): - def _b_job(): - time.sleep(0.02) - with c8_lock: - c8[0] += 1 - if c8[0] >= TOTAL_B: - c8_done.set() - pool_b.submit(_b_job) - pool_a.submit(_a_job) +print(f"[{elapsed():.3f}] All jobs complete — submitted={s.submitted} executed={s.executed} rejected={s.rejected}") -c8_done.wait(timeout=10.0) -sa = pool_a.stats() -sb = pool_b.stats() -print(f" pool_a stats: {sa.submitted} submitted, {sa.executed} executed, {sa.rejected} rejected") -print(f" pool_b stats: {sb.submitted} submitted, {sb.executed} executed, {sb.rejected} rejected") -passed &= check(name, sa.executed == NUM_A_JOBS, "pool_a should execute all A jobs") -passed &= check(name, sb.executed == TOTAL_B, "pool_b should execute all chained B jobs") -passed &= check(name, sb.rejected == 0, "pool_b should not reject any jobs") -pool_a.stop() -pool_b.stop() -results.append((name, passed)) - -# --------------------------------------------------------------------------- -# 9. Self-submit: a job submits another job back to the same pool -# --------------------------------------------------------------------------- -name = "self-submit: job submits to its own pool" -print(f"--- {name} ---") -passed = True - -NUM_INITIAL = 4 -TOTAL_EXEC = NUM_INITIAL * 2 # each initial job spawns one follow-up - -c9 = [0] -c9_lock = threading.Lock() -c9_done = threading.Event() - -pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) - -for _ in range(NUM_INITIAL): - def _initial(): - with c9_lock: - c9[0] += 1 - if c9[0] >= TOTAL_EXEC: - c9_done.set() - def _followup(): - time.sleep(0.01) - with c9_lock: - c9[0] += 1 - if c9[0] >= TOTAL_EXEC: - c9_done.set() - pool.submit(_followup) - pool.submit(_initial) - -c9_done.wait(timeout=5.0) -s = pool.stats() -print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") -passed &= check(name, s.submitted == TOTAL_EXEC, - "all initial + follow-up jobs should be submitted") -passed &= check(name, s.executed == TOTAL_EXEC, - "all initial + follow-up jobs should be executed") -passed &= check(name, s.rejected == 0, "no jobs should be rejected") pool.stop() -results.append((name, passed)) +print(f"[{elapsed():.3f}] Pool stopped, is_running={pool.is_running()}") -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- -print("==================== Results ====================") -total_passed = sum(1 for _, p in results if p) -for r_name, r_passed in results: - tag = "PASS" if r_passed else "FAIL" - print(f" {tag} {r_name}") -print("=================================================") -print(f"{total_passed}/{len(results)} tests passed") -if total_passed == len(results): - print("All tests passed!") - sys.exit(0) -else: - print(f"{len(results) - total_passed} test(s) FAILED") - sys.exit(1) +sys.exit(0) diff --git a/python/thread_pool_test.py b/python/thread_pool_test.py new file mode 100644 index 000000000..ec8fe89a7 --- /dev/null +++ b/python/thread_pool_test.py @@ -0,0 +1,406 @@ +"""ThreadPool Python test. + +Mirrors the 9 test sections of the C++ thread_pool_example.cpp, exercising +the full espp.ThreadPool Python binding API. + +Exit code 0 on full pass, 1 on any failure. +""" + +import sys +import threading +import time +from typing import List, Tuple + +import espp + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +results: List[Tuple[str, bool]] = [] + + +def check(test: str, condition: bool, desc: str) -> bool: + if condition: + print(f" PASS [{test}]: {desc}") + else: + print(f" FAIL [{test}]: {desc}") + return condition + + +def wait_for_jobs(event: threading.Event, counter, expected: int, timeout: float = 5.0) -> None: + event.wait(timeout=timeout) + + +# --------------------------------------------------------------------------- +# 1. Lifecycle: start / stop / is_running / worker_count +# --------------------------------------------------------------------------- +name = "lifecycle: start/stop/is_running/worker_count" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=3, auto_start=False)) +passed &= check(name, not pool.is_running(), "pool should not be running before start()") +passed &= check(name, pool.worker_count() == 3, "worker_count() should be 3") +passed &= check(name, pool.start(), "start() should return True on first call") +passed &= check(name, pool.is_running(), "pool should be running after start()") +passed &= check(name, pool.start(), "start() should return True when already running (no-op)") +passed &= check(name, pool.is_running(), "pool should still be running after duplicate start()") +pool.stop() +passed &= check(name, not pool.is_running(), "pool should not be running after stop()") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 2. submit() + queue_size() + stats() +# --------------------------------------------------------------------------- +name = "submit: normal dispatch + queue_size + stats" +print(f"--- {name} ---") +passed = True + +TOTAL_JOBS = 8 +counter = [0] +counter_lock = threading.Lock() +all_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +accepted = 0 +for i in range(TOTAL_JOBS): + def _job(i=i): + time.sleep(0.05) + with counter_lock: + counter[0] += 1 + if counter[0] >= TOTAL_JOBS: + all_done.set() + if pool.submit(_job): + accepted += 1 + +passed &= check(name, accepted == TOTAL_JOBS, "all jobs should be accepted (unbounded queue)") +all_done.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") +passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") +passed &= check(name, s.rejected == 0, "rejected count should be 0") +passed &= check(name, pool.queue_size() == 0, "queue should be empty after all jobs finish") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 3. try_submit() — deterministic rejection when queue is full +# --------------------------------------------------------------------------- +name = "try_submit: rejection when queue full" +print(f"--- {name} ---") +passed = True + +# 1 worker, capacity 2: 1 executing + 2 queued = 3 slots total. +# Gate workers with a barrier so the queue stays full during the rejection check. +# Track ALL 3 fill-job completions before calling pool.stop() to guarantee +# no worker thread is executing Python code when stop() joins the thread. +barrier = threading.Event() +first_started = threading.Event() +all_fill_done = threading.Event() +fill_done_count = [0] +fill_done_lock = threading.Lock() +TOTAL_FILL = 3 + +def _make_fill_job(signal_start: bool = False): + def _job(): + if signal_start: + first_started.set() + barrier.wait() + with fill_done_lock: + fill_done_count[0] += 1 + if fill_done_count[0] >= TOTAL_FILL: + all_fill_done.set() + return _job + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1, max_queue_size=2)) + +# Submit first job and wait until it is executing (off the queue) +pool.try_submit(_make_fill_job(signal_start=True)) +first_started.wait(timeout=2.0) + +# Fill the 2-slot queue +fill_accepted = 1 # first job counted above +for _ in range(2): + if pool.try_submit(_make_fill_job()): + fill_accepted += 1 + +passed &= check(name, fill_accepted == 3, "first 3 try_submit calls should be accepted") + +# Queue is now provably full +rejected_count = sum(1 for _ in range(3) if not pool.try_submit(lambda: None)) +passed &= check(name, rejected_count == 3, "try_submit when full should return False") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.rejected == 3, "stats.rejected should be 3") + +barrier.set() +# Wait for ALL 3 fill jobs to finish their Python callbacks before calling +# stop(). Waiting only for the first job is not enough — after it finishes, +# the worker immediately picks up the queued jobs and needs the GIL to execute +# them. stop() would deadlock if called while those callbacks are running. +all_fill_done.wait(timeout=5.0) +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 4. Queue backpressure: all jobs accepted despite bounded queue +# --------------------------------------------------------------------------- +# NOTE: ThreadPool.submit() is bound with a GIL-release guard, so blocking submit does not +# inherently deadlock Python worker callbacks due to the GIL. This section uses a try_submit() +# + sleep-retry loop to apply backpressure without blocking the calling thread. +# backpressure semantics safely. +name = "submit: queue backpressure (try_submit retry)" +print(f"--- {name} ---") +passed = True + +TOTAL_JOBS = 6 +counter2 = [0] +counter2_lock = threading.Lock() +all_done2 = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config( + worker_count=1, + max_queue_size=2, + block_on_submit_when_full=False, +)) + +accepted2 = 0 +for i in range(TOTAL_JOBS): + def _job2(i=i): + time.sleep(0.03) + with counter2_lock: + counter2[0] += 1 + if counter2[0] >= TOTAL_JOBS: + all_done2.set() + # Retry until accepted, releasing the GIL on each sleep so workers can run + while not pool.try_submit(_job2): + time.sleep(0.001) + accepted2 += 1 + +passed &= check(name, accepted2 == TOTAL_JOBS, "all jobs should be accepted (try_submit retry)") +all_done2.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") +passed &= check(name, s.executed == TOTAL_JOBS, "executed count should equal total_jobs") +passed &= check(name, s.rejected > 0, "rejected reflects try_submit retries (expected > 0)") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 5. submit() after stop() — rejected via is_running() guard +# --------------------------------------------------------------------------- +name = "submit: rejected after stop()" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=1)) +pool.stop() +accepted_after_stop = pool.submit(lambda: None) +passed &= check(name, not accepted_after_stop, "submit() after stop() should return False") +passed &= check(name, pool.stats().submitted == 0, "submitted count should be 0") +passed &= check(name, pool.stats().rejected == 1, "rejected count should be 1") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 6. Concurrent start/stop from multiple threads +# --------------------------------------------------------------------------- +name = "concurrent: start/stop from multiple threads" +print(f"--- {name} ---") +passed = True + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2, auto_start=False)) + +NUM_THREADS = 4 +ITERATIONS = 10 + +def _lifecycle_thread(t: int) -> None: + for i in range(ITERATIONS): + if (t + i) % 2 == 0: + pool.start() + else: + pool.stop() + +threads = [threading.Thread(target=_lifecycle_thread, args=(t,)) for t in range(NUM_THREADS)] +for t in threads: + t.start() +for t in threads: + t.join() + +pool.stop() +passed &= check(name, not pool.is_running(), "pool should reach a clean stopped state") +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == 0 and s.executed == 0 and s.rejected == 0, + "stats should all be zero (no jobs submitted)") +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 7. Concurrent submit and try_submit from multiple producer threads +# --------------------------------------------------------------------------- +name = "concurrent: multi-thread submit and try_submit" +print(f"--- {name} ---") +passed = True + +NUM_SUBMIT_THREADS = 3 +NUM_TRY_SUBMIT_THREADS = 2 +JOBS_PER_THREAD = 10 +TOTAL = (NUM_SUBMIT_THREADS + NUM_TRY_SUBMIT_THREADS) * JOBS_PER_THREAD + +c7 = [0] +c7_lock = threading.Lock() +c7_done = threading.Event() +total_accepted7 = [0] +ta7_lock = threading.Lock() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=4)) + +def _submit_producer(): + for _ in range(JOBS_PER_THREAD): + def _w(): + time.sleep(0.01) + with c7_lock: + c7[0] += 1 + if c7[0] >= TOTAL: + c7_done.set() + if pool.submit(_w): + with ta7_lock: + total_accepted7[0] += 1 + +def _try_submit_producer(): + for _ in range(JOBS_PER_THREAD): + def _w(): + time.sleep(0.01) + with c7_lock: + c7[0] += 1 + if c7[0] >= TOTAL: + c7_done.set() + if pool.try_submit(_w): + with ta7_lock: + total_accepted7[0] += 1 + +producers = ( + [threading.Thread(target=_submit_producer) for _ in range(NUM_SUBMIT_THREADS)] + + [threading.Thread(target=_try_submit_producer) for _ in range(NUM_TRY_SUBMIT_THREADS)] +) +for p in producers: + p.start() +for p in producers: + p.join() + +c7_done.wait(timeout=10.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted + s.rejected == TOTAL, + "submitted + rejected should equal total attempted") +passed &= check(name, s.executed == s.submitted, + "all accepted jobs should be executed (unbounded queue)") +passed &= check(name, s.rejected == 0, "unbounded queue should not reject any jobs") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 8. Chained pools: a job in pool_a submits work to pool_b +# --------------------------------------------------------------------------- +name = "chained: job in pool_a submits to pool_b" +print(f"--- {name} ---") +passed = True + +NUM_A_JOBS = 5 +B_JOBS_PER_A = 2 +TOTAL_B = NUM_A_JOBS * B_JOBS_PER_A + +c8 = [0] +c8_lock = threading.Lock() +c8_done = threading.Event() + +pool_b = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) +pool_a = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +for _ in range(NUM_A_JOBS): + def _a_job(): + for _ in range(B_JOBS_PER_A): + def _b_job(): + time.sleep(0.02) + with c8_lock: + c8[0] += 1 + if c8[0] >= TOTAL_B: + c8_done.set() + pool_b.submit(_b_job) + pool_a.submit(_a_job) + +c8_done.wait(timeout=10.0) +sa = pool_a.stats() +sb = pool_b.stats() +print(f" pool_a stats: {sa.submitted} submitted, {sa.executed} executed, {sa.rejected} rejected") +print(f" pool_b stats: {sb.submitted} submitted, {sb.executed} executed, {sb.rejected} rejected") +passed &= check(name, sa.executed == NUM_A_JOBS, "pool_a should execute all A jobs") +passed &= check(name, sb.executed == TOTAL_B, "pool_b should execute all chained B jobs") +passed &= check(name, sb.rejected == 0, "pool_b should not reject any jobs") +pool_a.stop() +pool_b.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# 9. Self-submit: a job submits another job back to the same pool +# --------------------------------------------------------------------------- +name = "self-submit: job submits to its own pool" +print(f"--- {name} ---") +passed = True + +NUM_INITIAL = 4 +TOTAL_EXEC = NUM_INITIAL * 2 # each initial job spawns one follow-up + +c9 = [0] +c9_lock = threading.Lock() +c9_done = threading.Event() + +pool = espp.ThreadPool(espp.ThreadPool.Config(worker_count=2)) + +for _ in range(NUM_INITIAL): + def _initial(): + with c9_lock: + c9[0] += 1 + if c9[0] >= TOTAL_EXEC: + c9_done.set() + def _followup(): + time.sleep(0.01) + with c9_lock: + c9[0] += 1 + if c9[0] >= TOTAL_EXEC: + c9_done.set() + pool.submit(_followup) + pool.submit(_initial) + +c9_done.wait(timeout=5.0) +s = pool.stats() +print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") +passed &= check(name, s.submitted == TOTAL_EXEC, + "all initial + follow-up jobs should be submitted") +passed &= check(name, s.executed == TOTAL_EXEC, + "all initial + follow-up jobs should be executed") +passed &= check(name, s.rejected == 0, "no jobs should be rejected") +pool.stop() +results.append((name, passed)) + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +print("==================== Results ====================") +total_passed = sum(1 for _, p in results if p) +for r_name, r_passed in results: + tag = "PASS" if r_passed else "FAIL" + print(f" {tag} {r_name}") +print("=================================================") +print(f"{total_passed}/{len(results)} tests passed") +if total_passed == len(results): + print("All tests passed!") + sys.exit(0) +else: + print(f"{len(results) - total_passed} test(s) FAILED") + sys.exit(1) From 41c9463c7713fc11a5d4e60d5813bfff7015ca45 Mon Sep 17 00:00:00 2001 From: Guo Date: Mon, 27 Jul 2026 16:31:52 -0500 Subject: [PATCH 9/9] update python test and example --- python/thread_pool.py | 87 ++++++++++++++++++++------------------ python/thread_pool_test.py | 25 ++++++----- 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/python/thread_pool.py b/python/thread_pool.py index 071fbe1d7..ab4da0b2c 100644 --- a/python/thread_pool.py +++ b/python/thread_pool.py @@ -4,44 +4,49 @@ import espp -# --------------------------------------------------------------------------- -# Basic usage: submit jobs, wait for completion, read stats -# --------------------------------------------------------------------------- -start = time.time() -def elapsed(): - return time.time() - start - -counter_lock = threading.Lock() -completed = [0] -done = threading.Event() -TOTAL_JOBS = 8 - -pool = espp.ThreadPool(espp.ThreadPool.Config( - worker_count=2, - max_queue_size=0, - auto_start=True, - log_level=espp.Logger.Verbosity.warn, -)) - -print(f"[{elapsed():.3f}] Pool started, worker_count={pool.worker_count()}, is_running={pool.is_running()}") - -for i in range(TOTAL_JOBS): - def _job(i=i): - time.sleep(0.2) - with counter_lock: - completed[0] += 1 - print(f"[{elapsed():.3f}] Job {i} done ({completed[0]}/{TOTAL_JOBS})") - if completed[0] >= TOTAL_JOBS: - done.set() - pool.submit(_job) - -print(f"[{elapsed():.3f}] All {TOTAL_JOBS} jobs submitted, queue_size={pool.queue_size()}") - -done.wait() -s = pool.stats() -print(f"[{elapsed():.3f}] All jobs complete — submitted={s.submitted} executed={s.executed} rejected={s.rejected}") - -pool.stop() -print(f"[{elapsed():.3f}] Pool stopped, is_running={pool.is_running()}") - -sys.exit(0) + +def main() -> None: + # --------------------------------------------------------------------------- + # Basic usage: submit jobs, wait for completion, read stats + # --------------------------------------------------------------------------- + start = time.time() + def elapsed(): + return time.time() - start + + counter_lock = threading.Lock() + completed = [0] + done = threading.Event() + TOTAL_JOBS = 8 + + pool = espp.ThreadPool(espp.ThreadPool.Config( + worker_count=2, + max_queue_size=0, + auto_start=True, + log_level=espp.Logger.Verbosity.warn, + )) + + print(f"[{elapsed():.3f}] Pool started, worker_count={pool.worker_count()}, is_running={pool.is_running()}") + + for i in range(TOTAL_JOBS): + def _job(i=i): + time.sleep(0.2) + with counter_lock: + completed[0] += 1 + print(f"[{elapsed():.3f}] Job {i} done ({completed[0]}/{TOTAL_JOBS})") + if completed[0] >= TOTAL_JOBS: + done.set() + pool.submit(_job) + + print(f"[{elapsed():.3f}] All {TOTAL_JOBS} jobs submitted, queue_size={pool.queue_size()}") + + done.wait() + s = pool.stats() + print(f"[{elapsed():.3f}] All jobs complete — submitted={s.submitted} executed={s.executed} rejected={s.rejected}") + + pool.stop() + print(f"[{elapsed():.3f}] Pool stopped, is_running={pool.is_running()}") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/python/thread_pool_test.py b/python/thread_pool_test.py index ec8fe89a7..8033e4bbd 100644 --- a/python/thread_pool_test.py +++ b/python/thread_pool_test.py @@ -28,10 +28,6 @@ def check(test: str, condition: bool, desc: str) -> bool: return condition -def wait_for_jobs(event: threading.Event, counter, expected: int, timeout: float = 5.0) -> None: - event.wait(timeout=timeout) - - # --------------------------------------------------------------------------- # 1. Lifecycle: start / stop / is_running / worker_count # --------------------------------------------------------------------------- @@ -76,7 +72,8 @@ def _job(i=i): accepted += 1 passed &= check(name, accepted == TOTAL_JOBS, "all jobs should be accepted (unbounded queue)") -all_done.wait(timeout=10.0) +if not all_done.wait(timeout=10.0): + passed &= check(name, False, "all jobs should complete within 10 s timeout") s = pool.stats() print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") @@ -119,7 +116,8 @@ def _job(): # Submit first job and wait until it is executing (off the queue) pool.try_submit(_make_fill_job(signal_start=True)) -first_started.wait(timeout=2.0) +if not first_started.wait(timeout=2.0): + passed &= check(name, False, "first fill job should start within 2 s timeout") # Fill the 2-slot queue fill_accepted = 1 # first job counted above @@ -141,7 +139,8 @@ def _job(): # stop(). Waiting only for the first job is not enough — after it finishes, # the worker immediately picks up the queued jobs and needs the GIL to execute # them. stop() would deadlock if called while those callbacks are running. -all_fill_done.wait(timeout=5.0) +if not all_fill_done.wait(timeout=5.0): + passed &= check(name, False, "all fill jobs should complete within 5 s timeout") pool.stop() results.append((name, passed)) @@ -181,7 +180,8 @@ def _job2(i=i): accepted2 += 1 passed &= check(name, accepted2 == TOTAL_JOBS, "all jobs should be accepted (try_submit retry)") -all_done2.wait(timeout=10.0) +if not all_done2.wait(timeout=10.0): + passed &= check(name, False, "all backpressure jobs should complete within 10 s timeout") s = pool.stats() print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") passed &= check(name, s.submitted == TOTAL_JOBS, "submitted count should equal total_jobs") @@ -293,7 +293,8 @@ def _w(): for p in producers: p.join() -c7_done.wait(timeout=10.0) +if not c7_done.wait(timeout=10.0): + passed &= check(name, False, "all concurrent jobs should complete within 10 s timeout") s = pool.stats() print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") passed &= check(name, s.submitted + s.rejected == TOTAL, @@ -334,7 +335,8 @@ def _b_job(): pool_b.submit(_b_job) pool_a.submit(_a_job) -c8_done.wait(timeout=10.0) +if not c8_done.wait(timeout=10.0): + passed &= check(name, False, "all chained B jobs should complete within 10 s timeout") sa = pool_a.stats() sb = pool_b.stats() print(f" pool_a stats: {sa.submitted} submitted, {sa.executed} executed, {sa.rejected} rejected") @@ -377,7 +379,8 @@ def _followup(): pool.submit(_followup) pool.submit(_initial) -c9_done.wait(timeout=5.0) +if not c9_done.wait(timeout=5.0): + passed &= check(name, False, "all self-submit jobs should complete within 5 s timeout") s = pool.stats() print(f" stats: {s.submitted} submitted, {s.executed} executed, {s.rejected} rejected") passed &= check(name, s.submitted == TOTAL_EXEC,