Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Core source files in `src/` have no `#ifdef ESP_PLATFORM` guards; all platform d

- **ESP-IDF**: Used as an IDF component via `idf_component.yml`. Sources defined in `cmake/sources.cmake`.
- **Host (CMake)**: `cmake -B build && cmake --build build`. Uses ccache when installed (`SENDSPIN_USE_CCACHE`, default on when standalone). Fetches dependencies via FetchContent: ArduinoJson, noise-c, IXWebSocket, micro-flac for the player or source role, and micro-opus for the player or source role when `SENDSPIN_ENABLE_OPUS` is on.
- **Tests**: `cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tests --target sendspin_tests` and `ctest --test-dir build-tests --output-on-failure`. The target also builds `sendspin_alloc_tests`, which counts the library's `operator new` calls from accept to admission, from `connect_to()` to admission, for a `client/state`, for a time burst, for a controller `server/state`, for the `stream/*` messages, for the sync task's first FLAC codec header, for a FLAC `stream/start` the player takes, for a `stream/start` section the connection's roles do not take, for the first spectrum frame, and through the record flush that follows each of a playback handoff to another paired server, a `server/unpair`, a `pairing_psk` pairing and a dynamic pairing code pairing in each emission format; `SENDSPIN_ALLOC_REPORT=1` prints them grouped by call site.
- **Tests**: `cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tests --target sendspin_tests` and `ctest --test-dir build-tests --output-on-failure`. The target also builds `sendspin_alloc_tests`, which counts the library's `operator new` calls from accept to admission, from `connect_to()` to admission, for a `client/state`, for a time burst, for a controller `server/state`, for `group/update`, for the `stream/*` messages, for the sync task's first FLAC codec header, for a FLAC `stream/start` the player takes, for a `stream/start` section the connection's roles do not take, for the first spectrum frame, and through the record flush that follows each of a playback handoff to another paired server, a `server/unpair`, a `pairing_psk` pairing and a dynamic pairing code pairing in each emission format; `SENDSPIN_ALLOC_REPORT=1` prints them grouped by call site.
- **ThreadSanitizer tests**: `cmake -B build-tsan -DSENDSPIN_BUILD_TESTS=ON -DENABLE_TSAN=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tsan --target sendspin_tests` and `TSAN_OPTIONS=halt_on_error=1 ctest --test-dir build-tsan --output-on-failure`. `ENABLE_TSAN` applies the thread sanitizer to every target, including the fetched dependencies, and cannot be combined with `ENABLE_SANITIZERS`.
- **Message trace**: `-DSENDSPIN_ENABLE_MESSAGE_TRACE=ON` compiles in `SendspinClientConfig::message_trace` and `tests/test_message_trace.cpp`; CI's test and TSan jobs build it on, the build and no-opus jobs off.
- **Stack budgets**: the ESP task stack defaults in `config.h` are static call-graph bounds from `tools/stack_usage/` (recipe and per-task figures in its README); re-derive them, and update the README table with them, after a change that deepens a task's call chain.
Expand Down
5 changes: 4 additions & 1 deletion docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,10 @@ Receives client-level events. All callbacks fire on the main loop thread.

```cpp
struct MyClientListener : SendspinClientListener {
// Called when group state changes (playback state, group name, etc.)
// Called when group state changes (playback state, group name, etc.). An empty group_id
// means the group was cleared. A non-empty server group_id that is not 43 base64url
// characters arrives as base64url of its SHA-256, which still names one group; a future
// release closes the connection on such an id instead.
void on_group_update(const GroupUpdateObject& group) override {
if (group.playback_state) update_playback_indicator(*group.playback_state);
if (group.group_name) update_group_display(*group.group_name);
Expand Down
8 changes: 4 additions & 4 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ Role-bound traffic reaches a role only from the admitted connection that owns it
| `server/time` | Claims the connection's `client/time` frame in flight, dropping a reply that answers none, and feeds the measurement to the connection's own time burst |
| `server/state` | Writes the controller, metadata, and color `InboxSlot`s |
| `server/command` | Merges the player object into the player's `command_slot`; the source object starts or ends the input stream (see [Source Stream Start and End](#source-stream-start-and-end)) |
| `group/update` | Merges into `group_slot`, from the primary admitted connection only |
| `group/update` | Merges into `group_slot`, from the primary admitted connection only; a `group_id` that is not 43 base64url characters is normalized, or closes the connection when its hash state cannot be allocated; with `GROUP_ID_STRICT`, one that is absent or not in that format closes the connection (`group_id_from_wire()`) |
| `stream/start` | Player writes the codec header, numbered with the stream's ordinal, into an item it acquires from the inbound ring (a bounded wait) and appends it to the sync task's list, then writes its params slot and pushes STREAM_START; visualizer moves its boundary sequence on (below), writes its config slot, and pushes STREAM_START; artwork marks its stream active, discards the pending image of each channel whose configuration changed, and appends a marker naming those channels to its decode thread's list |
| `stream/end` | Pushes STREAM_END events for each streaming role, signals the sync task with the ordinal the stream ended and releases its listed audio from the player's quota, recalls the visualizer's listed frames and moves its boundary sequence on (`VisualizerRole::Impl::signal_boundary()`; every stream command and `cleanup()` do both, and the drain thread returns the frame it holds), and appends a marker for every channel to the artwork decode thread's list |
| `stream/clear` | Visualizer moves its boundary sequence on (see `stream/end`) and pushes STREAM_CLEAR; player signals the sync task, releases its listed audio from the player's quota and appends a clear marker to its list |
Expand Down Expand Up @@ -456,15 +456,15 @@ On host, IXWebSocket callbacks hold a `weak_ptr` to the connection the manager a

## Memory

The library allocates from heaps that fail differently. `operator new`, behind every `std::string`, `std::vector` and `std::make_shared` and any `std::function` whose capture outgrows its small buffer, draws on ESPHome only on internal RAM and aborts when it fails. The `platform_malloc` family (`src/platform/memory.h`, behind `PlatformBuffer` and the JSON arena's overflow) places each block by its `MemoryLocation`, and noise-c allocates its handshake, cipher and hash states with `calloc`; both report a failure, which the caller turns into a refused or closed connection or a dropped message. The exceptions are hashing a PSK into its `psk_id`, which aborts when noise-c cannot allocate the hash state, but only while `start()` loads the record store (`psk_id_for()` on a `std::array`), and deriving `SENTINEL_PSK` the same way at static initialization; a pairing that cannot hash its new PSK fails instead. Construction, `add_*()` and `start()` use any of them. After `start()`, the per-connection and per-message buffers come from the `platform_malloc` family: the connection object of an ESP accept and of every `connect_to()`, with its `shared_ptr` control block (`make_connection_in_block()`: `std::allocate_shared()` into a block reserved first, so a failure refuses the session or the connect), the JSON arena's overflow, the handshake's prologue and msg1 buffers, the fallback buffer, the Noise transport's reassembly and send buffers, and on ESP a send that does not fit the `FixedBlockPool`. The sync task allocates on first use from the same family: its decode buffer's storage at the first codec header (the `TransferBuffer` itself sits by value in the task's `SyncContext`), where a failure drops the stream, and the FLAC decoder, constructed in a `PlatformBuffer` at the first FLAC codec header and reset for each later FLAC stream, where a failure fails the header and the stream's chunks are discarded unplayed. micro-flac allocates the rest of its state with `FLAC_MALLOC`, which fails the same way; its own `operator new` allocations, an Ogg demuxer and stored metadata blocks, never run on the `fLaC` and last-STREAMINFO `codec_header` the parser admits. The visualizer's spectrum bins are reserved by `start()` for the configured `n_disp_bins` when the configuration requests the spectrum type, and a stream serving more bins than that drops its spectrum frames rather than growing them on the drain thread. `connect_to()` takes its URL by value and moves it through the request slot into the transport, so the library copies none of it. The host transport, whose IXWebSocket API copies each frame into a `std::string` and which makes a host accept's connection object with `std::make_shared`, and the host-only message trace sit outside this model: ESP replaces the first with esp_http_server and esp_websocket_client and builds without the second.
The library allocates from heaps that fail differently. `operator new`, behind every `std::string`, `std::vector` and `std::make_shared` and any `std::function` whose capture outgrows its small buffer, draws on ESPHome only on internal RAM and aborts when it fails. The `platform_malloc` family (`src/platform/memory.h`, behind `PlatformBuffer` and the JSON arena's overflow) places each block by its `MemoryLocation`, and noise-c allocates its handshake, cipher and hash states with `calloc`; both report a failure, which the caller turns into a refused or closed connection or a dropped message. Normalizing a `group_id` that is not 43 base64url characters (`group_id_from_wire()`, while `GROUP_ID_STRICT` is off) has noise-c `calloc` a SHA-256 state on the protocol task for every `group/update` from a server that sends such ids; when that allocation fails, the connection is closed, as for a non-conforming id under `GROUP_ID_STRICT`, rather than applying the rest of the update under the previous group's id. The exceptions are hashing a PSK into its `psk_id`, which aborts when noise-c cannot allocate the hash state, but only while `start()` loads the record store (`psk_id_for()` on a `std::array`), and deriving `SENTINEL_PSK` the same way at static initialization; a pairing that cannot hash its new PSK fails instead. Construction, `add_*()` and `start()` use any of them. After `start()`, the per-connection and per-message buffers come from the `platform_malloc` family: the connection object of an ESP accept and of every `connect_to()`, with its `shared_ptr` control block (`make_connection_in_block()`: `std::allocate_shared()` into a block reserved first, so a failure refuses the session or the connect), the JSON arena's overflow, the handshake's prologue and msg1 buffers, the fallback buffer, the Noise transport's reassembly and send buffers, and on ESP a send that does not fit the `FixedBlockPool`. The sync task allocates on first use from the same family: its decode buffer's storage at the first codec header (the `TransferBuffer` itself sits by value in the task's `SyncContext`), where a failure drops the stream, and the FLAC decoder, constructed in a `PlatformBuffer` at the first FLAC codec header and reset for each later FLAC stream, where a failure fails the header and the stream's chunks are discarded unplayed. micro-flac allocates the rest of its state with `FLAC_MALLOC`, which fails the same way; its own `operator new` allocations, an Ogg demuxer and stored metadata blocks, never run on the `fLaC` and last-STREAMINFO `codec_header` the parser admits. The visualizer's spectrum bins are reserved by `start()` for the configured `n_disp_bins` when the configuration requests the spectrum type, and a stream serving more bins than that drops its spectrum frames rather than growing them on the drain thread. `connect_to()` takes its URL by value and moves it through the request slot into the transport, so the library copies none of it. The host transport, whose IXWebSocket API copies each frame into a `std::string` and which makes a host accept's connection object with `std::make_shared`, and the host-only message trace sit outside this model: ESP replaces the first with esp_http_server and esp_websocket_client and builds without the second.

Bounded values stay inline: the ids and names a connection holds (`IdText`, `ServerName`), activity, player and controller command, visualizer type and source codec sets (`EnumSet`), role sets, `stream/start`'s artwork channels (`InlineVector`) and its player's FLAC codec header, decoded as it is parsed (`FlacCodecHeader`), the protocol task's slot tables and the ESP server's session slots and pending upgrades (`InlineVector`, `std::array`), each connection's `NoiseSession`, which owns the noise-c handshake and cipher states, and `NoiseHandshake`, a pairing attempt's CPace state, its PRS and the code it emits (`InlineText`), and the pairing records (`PairingRecord`, its ids `IdText`): the one a pairing holds pending on its connection and those the record store holds, reserved for `max_pairing_records` when `start()` builds the store. The store's mutators mark the slots a flush owes in a bitset, and the flush encodes one write at a time into a buffer on the main loop's stack. CPace hashes each length-value piece of its transcript as it encodes it rather than building the transcript first. The pairing notes for the main loop trade two buffers reserved at construction between the slot and the drain (`InboxSlot::exchange()`). Role sets take two shapes: ownership, admission and `client/state` intersect and complement them, so `ActiveRoles` and the admitted slots hold `uint16_t` role masks (`role_mask_bit()`, `ALL_ROLES_MASK`), while the roles a `stream/start`, `stream/end` or `stream/clear` covers are only filled and looked up, so they are an `EnumSet<SendspinRole>`. `client/state`'s artwork channels and visualizer types view the role's configuration, fixed from `add_*()`, which may be called only while the client is stopped (`client.h` "Role registration"), and `client/hello`'s application roles view the client's configuration as `start()` normalized it. A getter that returns `std::string`, such as the server's id and name, builds it from the inline value on the caller's thread. The main loop lists the controller's `supported_commands` and a visualizer stream's `types` from their sets into public vectors each role reserves for every member when it is built.
Bounded values stay inline: the ids and names a connection holds (`IdText`, `ServerName`), activity, player and controller command, visualizer type and source codec sets (`EnumSet`), role sets, `stream/start`'s artwork channels (`InlineVector`) and its player's FLAC codec header, decoded as it is parsed (`FlacCodecHeader`), the protocol task's slot tables and the ESP server's session slots and pending upgrades (`InlineVector`, `std::array`), each connection's `NoiseSession`, which owns the noise-c handshake and cipher states, and `NoiseHandshake`, a pairing attempt's CPace state, its PRS and the code it emits (`InlineText`), and the pairing records (`PairingRecord`, its ids `IdText`): the one a pairing holds pending on its connection and those the record store holds, reserved for `max_pairing_records` when `start()` builds the store. The store's mutators mark the slots a flush owes in a bitset, and the flush encodes one write at a time into a buffer on the main loop's stack. CPace hashes each length-value piece of its transcript as it encodes it rather than building the transcript first. The pairing notes for the main loop trade two buffers reserved at construction between the slot and the drain (`InboxSlot::exchange()`). Role sets take two shapes: ownership, admission and `client/state` intersect and complement them, so `ActiveRoles` and the admitted slots hold `uint16_t` role masks (`role_mask_bit()`, `ALL_ROLES_MASK`), while the roles a `stream/start`, `stream/end` or `stream/clear` covers are only filled and looked up, so they are an `EnumSet<SendspinRole>`. `client/state`'s artwork channels and visualizer types view the role's configuration, fixed from `add_*()`, which may be called only while the client is stopped (`client.h` "Role registration"), and `client/hello`'s application roles view the client's configuration as `start()` normalized it. A getter that returns `std::string`, such as the server's id and name, builds it from the inline value on the caller's thread. The main loop lists the controller's `supported_commands` and a visualizer stream's `types` from their sets into public vectors each role reserves for every member when it is built, and copies `group/update`'s `group_id` and `group_name`, parsed into `GroupUpdatePayload` (`IdText`, `ServerName`), into the public strings of the client's group state and of the delta `on_group_update()` is handed, each reserved for its bound when the client is built and swapped aside rather than freed when its field is cleared (`ReservedText`, `src/reserved_text.h`).

`operator new` runs after `start()` at these places:

| What | When |
|------|------|
| Message fields held on the heap: track metadata, and `group/update`'s `group_id` and `group_name` | Each message that carries them, parsed on the protocol task; a role's fields are parsed only for a role the connection owns. `group/update`'s fields are copied again into the inbox slot's merged value on the protocol task and into the client's group state on the main loop |
| Message fields held on the heap: track metadata | Each message that carries them, parsed on the protocol task, only for a connection that owns the metadata role |
| The listener's `std::string` arguments: a pairing's `server_id`, and a `qr_code` pairing code's token | Each pairing callback that takes one, on the main loop |
| The pairing notes for the main loop beyond the room both of their buffers keep | A batch of more than `PAIRING_NOTE_CAPACITY` notes between two drains, on the protocol task; the grown room is kept |
| Another drain buffer for the pairing notes, with room for `PAIRING_NOTE_CAPACITY` | A pairing listener callback that calls `stop()` while a prompt shows: the drain `stop()` runs finds the buffer lent to the dispatch that called it, and reserves one before it takes the dismissals, on the main loop |
Expand Down
3 changes: 2 additions & 1 deletion include/sendspin/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ class SendspinArenaAllocator;
class SendspinConnection;
struct ClientHelloMessage;
struct ClientStateMessage;
struct GroupUpdatePayload;
struct Identity;

/**
Expand Down Expand Up @@ -919,7 +920,7 @@ class SendspinClient {

/// @brief Folds a group/update delta from the primary admitted connection into group_slot
/// for the main loop. Protocol task only.
void merge_group_update(GroupUpdateObject&& delta);
void merge_group_update(const GroupUpdatePayload& delta);

// ========================================
// Persistence & identity
Expand Down
2 changes: 2 additions & 0 deletions include/sendspin/types.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ enum class SendspinPlaybackState : uint8_t {
/// @brief Group membership and playback state delta received in group/update messages
struct GroupUpdateObject {
std::optional<SendspinPlaybackState> playback_state{};
/// 43 base64url characters, or empty when cleared; a server id in another form is reported as
/// its SHA-256 in base64url
std::optional<std::string> group_id{};
/// At most 64 bytes in UTF-8, cut at a character boundary
std::optional<std::string> group_name{};
Expand Down
Loading
Loading