This document describes the internal architecture of the sendspin-cpp library, focusing on threading, inter-class communication, and the ordering guarantees that keep everything correct.
Each role class uses the pimpl (pointer to implementation) pattern. The public header (include/sendspin/<role>_role.h) exposes only the consumer-facing API: protocol types, the listener interface, and a thin role class with struct Impl; std::unique_ptr<Impl> impl_;. All private state, internal methods, and thread-management code live in a private impl header (src/<role>_role_impl.h) and the corresponding .cpp file.
SendspinClient is a friend of each role class, giving it access to impl_-> for internal dispatch (message routing, event draining, lifecycle management). The SyncTask holds a PlayerRole::Impl* directly (passed at init time), so it accesses player state without indirection through the public PlayerRole class.
Throughout this document, internal field and method references use the Impl qualification (e.g., PlayerRole::Impl::drain_events()) to reflect the actual code location.
Roles can be disabled at build time via SENDSPIN_ENABLE_* (CMake options on host, Kconfig entries on ESP-IDF). Two mechanisms cooperate, with a strict boundary between them:
- CMake source-list exclusion (
cmake/sources.cmake). Each role has its ownSENDSPIN_<ROLE>_SOURCESlist. When a role is disabled, its translation units are not added to the build, so the code never compiles and its transitive dependencies are not required; e.g., micro-flac and micro-opus for the player. The ESP-IDF component manifest (idf_component.yml) similarly gates the audio codec dependencies onSENDSPIN_ENABLE_PLAYERso they are not even fetched. #ifdef SENDSPIN_ENABLE_<ROLE>guards ininclude/sendspin/client.handsrc/client.cpp. These are the only core files that must reference role types directly (thestd::unique_ptr<RoleClass>members,add_*()/ accessor declarations, and dispatch branches in message handlers). Nowhere else in the core should use these guards.
The split exists because the two problems are different. CMake handles "don't compile this file and don't require its dependencies," while #ifdef handles "core code needs to conditionally mention a type." Using #ifdef to gate entire files would still force the codec headers onto the include path; using CMake to gate individual member declarations is not possible.
As a consequence, role-only headers;e.g., src/decoder.h, which pulls in <micro_flac/flac_decoder.h> and <opus.h>, must only be reachable through role-only sources or through #ifdef-guarded includes in client.cpp. Public role headers in include/sendspin/ must remain free of codec dependencies so that core files like src/transfer_buffer.cpp and src/protocol_messages.h can include them unconditionally.
When adding a new role, the checklist is: add a SENDSPIN_<ROLE>_SOURCES list in cmake/sources.cmake, add the member/accessor/dispatch guards in client.h and client.cpp, and keep any heavy dependencies behind the role's private headers.
A separate #ifdef axis lives in src/platform/: headers there use #ifdef ESP_PLATFORM to select between ESP-IDF (FreeRTOS, heap_caps_malloc, esp_log, etc.) and host (std primitives, malloc, printf) implementations behind a common API. This is orthogonal to role selection; the split is between build targets, not features. Core sources outside src/platform/, src/esp/, and src/host/ should never use #ifdef ESP_PLATFORM directly, so platform differences stay isolated to the abstraction layer.
The library uses a small number of long-lived threads. All state mutations and user-facing callbacks happen on the caller's main loop thread unless explicitly noted otherwise.
| Thread | Name | Created by | Stack (ESP) | Priority (ESP) | Purpose |
|---|---|---|---|---|---|
| Main loop | (caller's) | User code | - | - | Drives SendspinClient::loop(). All role event processing and listener callbacks run here. |
| Sync task | Sendspin |
PlayerRole::Impl::start() → SyncTask::start() |
6192 B | 2 | Decodes audio, synchronizes to server timestamps, writes PCM to the audio sink via on_audio_write. |
| Visualizer drain | SsVis |
VisualizerRole::Impl::start() |
4096 B | 2 | Reads visualization frames from a ring buffer and delivers them to the listener at the correct playback time. |
| Artwork decode | SsArt |
ArtworkRole::Impl::start() |
4096 B | 2 | Receives image notifications and calls the decode callback. Hands the server display timestamp off to the main loop, which fires the display callback at the correct time. |
| Network | (library-internal) | IXWebSocket (host) or esp_http_server (ESP) | - | - | WebSocket I/O. Callbacks fire on these threads and must defer work to the main loop. |
On host builds, platform_configure_thread() is a no-op; threads use OS defaults. On ESP-IDF it calls esp_pthread_set_cfg() to set stack size, priority, name, and optional PSRAM allocation before the std::thread is constructed.
Sync task (src/sync_task.cpp:620):
SyncTask::start()configures the thread and spawns it.- The caller blocks until the thread reaches IDLE state (
TASK_IDLEevent flag) or exits early due to an allocation failure (TASK_STOPPED). - The thread runs a persistent outer loop for the lifetime of the client.
SyncTask::stop()setsCOMMAND_STOPand joins the thread. Called fromSyncTask's destructor, which is triggered bysync_task_.reset()inPlayerRole::Impl's destructor.
Visualizer drain (src/visualizer_role.cpp:120):
VisualizerRole::Impl::start()spawns the drain thread.- The thread blocks on ring buffer receives with a 50 ms timeout.
VisualizerRole::Impldestructor setsCOMMAND_STOPand joins.
Artwork decode (src/artwork_role.cpp):
ArtworkRole::Impl::start()spawns the decode thread.- The thread blocks on notification queue receives with a 100 ms timeout.
- On notification: calls
on_image_decode(), then writes the server display timestamp into a per-slotShadowSlot<int64_t>(DisplayScheduler::pending[slot]). The main loop'sArtworkRole::Impl::drain_events()fireson_image_display()once the timestamp is reached. Latest-wins per slot: if a newer frame's timestamp overwrites the pending one before the main loop takes it, only the newer display fires. ArtworkRole::Impldestructor setsCOMMAND_STOPand joins.
Destruction order matters because external audio callbacks may still reference the sync task. PlayerRole::Impl's destructor resets the sync task first (sync_task_.reset()) before tearing down anything else, so the thread is fully joined before any shared state is destroyed.
All primitives are abstracted in src/platform/ with ESP-IDF (FreeRTOS) and host (std::mutex/condition_variable) implementations.
Atomic bit flags with blocking wait. Used for thread lifecycle control:
COMMAND_STOP (1 << 0) Stop the thread
COMMAND_STREAM_END (1 << 1) End current stream
COMMAND_STREAM_CLEAR (1 << 2) Seek: discard buffered audio up to the stream/clear marker
COMMAND_START (1 << 3) Main loop acknowledged stream start
TASK_RUNNING (1 << 8) Actively decoding
TASK_STOPPED (1 << 10) Thread has exited
TASK_ERROR (1 << 11) Allocation or decode failure
TASK_IDLE (1 << 12) Waiting for work
The sync task, visualizer drain thread, and artwork decode thread all use event flags for command signaling from the main loop and status reporting back. The artwork decode thread and visualizer drain thread use a simpler subset: COMMAND_STOP and COMMAND_FLUSH.
Fixed-depth FIFO queue with timed send/receive. Used to defer events from network threads to the main loop:
| Queue | Depth | Data | Producer | Consumer |
|---|---|---|---|---|
PlayerRole::Impl::stream_queue |
8 | PlayerStreamCallbackType |
Network thread | Main loop (drain_events) |
PlayerRole::Impl::state_queue |
4 | SendspinClientState |
Sync task thread | Main loop (drain_events) |
Client::time_queue |
16 | TimeResponseEvent |
Network thread | Main loop (loop) |
ArtworkRole::Impl::notify_queue |
8 | ArtworkNotification |
Network thread | Artwork decode thread |
ArtworkRole::Impl::queue |
8 | ArtworkEventType |
Network thread | Main loop (drain_events) |
VisualizerRole::Impl::queue |
8 | VisualizerEventType |
Network thread | Main loop (drain_events) |
Single-slot state container with "latest wins" or custom merge semantics. The network thread writes or merges; the main loop takes the accumulated value:
| Shadow Slot | Data | Merge Strategy |
|---|---|---|
Client::shadow_group |
GroupUpdateObject |
Field-by-field delta merge |
PlayerRole::Impl::shadow_stream_params |
ServerPlayerStreamObject |
Latest wins |
PlayerRole::Impl::shadow_command |
ServerCommandMessage |
Field-by-field merge (volume, mute, delay independent) |
ControllerRole::Impl::shadow |
ServerStateControllerObject |
Latest wins |
MetadataRole::Impl::shadow |
ServerMetadataStateDelta |
Field-by-field delta merge (preserves pending clears across rapid updates) |
ColorRole::Impl::shadow |
ServerColorStateDelta |
Field-by-field delta merge (preserves pending clears across rapid updates) |
ArtworkRole::Impl::display_scheduler->pending[slot] (×4) |
int64_t (server display timestamp) |
Latest wins |
VisualizerRole::Impl::shadow_config |
ServerVisualizerStreamObject |
Latest wins |
SyncTask::playback_progress_slot_ |
PlaybackProgress |
Sum frames_played, keep latest finish_timestamp |
The merge strategy for shadow_command is important: if a volume change and a mute change arrive between two drain ticks, both are preserved because the merge function only overwrites fields that have values in the delta.
Single-producer/single-consumer ring buffer for variable-size binary data. Two-phase API: acquire → commit (producer), receive → return_item (consumer). Also supports a single-phase send for the producer.
Used for:
- Encoded audio: Via the
SendspinAudioRingBufferwrapper (which adds chunk headers and exposeswrite_chunk/receive_chunk/return_chunk). Network thread writes chunks; sync task reads and decodes them. - Visualizer frames: Used directly. Network thread writes frame/beat entries; drain thread reads them at the correct playback time.
std::mutexonConnectionManager::conn_mutex_: protects deferred connection event vectors.std::mutexonSendspinTimeFilter::state_mutex_: protects Kalman filter state (offset, drift, covariance).std::atomic<bool>onSendspinConnection::message_dispatch_enabled_: allows the main loop to instantly suppress message delivery from the network thread.std::atomic<bool/uint8_t/size_t>onVisualizerRole::Impl: network thread writes stream config atomically; drain thread reads it.std::atomic<bool>onArtworkRole::Impl::stream_active: guardshandle_binary()from writing when no stream is active.std::atomic<uint8_t>onArtworkRole::Impl::SlotBuffer::write_idx: tracks which of two double-buffers the network thread writes to next.std::atomic<bool>onArtworkRole::Impl::SlotBuffer::drain_active: set by the decode thread while decoding, checked by the network thread to avoid overwriting an in-use buffer.std::atomic<uint8_t>onSendspinClient::high_performance_ref_count_: ref-counted high-performance networking requests from time sync and playback.
Network thread (IXWebSocket / esp_http_server)
│
├─ Assembles fragmented WebSocket frames into complete messages
│ (connection.cpp: prepare_receive_buffer_ / commit_receive_buffer_)
│
├─ Checks message_dispatch_enabled_ atomic flag
│ (returns immediately if disabled; used during teardown)
│
└─ Invokes callback on network thread:
├─ Text → SendspinClient::process_json_message_()
└─ Binary → SendspinClient::process_binary_message_()
process_json_message() (src/client.cpp) parses the message type and routes:
| Message | Action on Network Thread |
|---|---|
SERVER_HELLO |
Enqueues ServerHelloEvent into ConnectionManager's mutex-protected vector |
SERVER_TIME |
Enqueues TimeResponseEvent into time_queue |
SERVER_STATE |
Writes to ControllerRole::Impl::shadow, MetadataRole::Impl::shadow, and ColorRole::Impl::shadow |
SERVER_COMMAND |
Merges into PlayerRole::Impl::shadow_command |
GROUP_UPDATE |
Merges into Client::shadow_group |
STREAM_START |
Writes to PlayerRole::Impl::shadow_stream_params, enqueues STREAM_START into stream_queue. Marks the artwork stream active, flushes the decode thread's notification queue, and resets any pending per-slot display timestamps. Writes to VisualizerRole::Impl::shadow_config, enqueues a start event. |
STREAM_END |
Enqueues STREAM_END into player/artwork/visualizer queues, signals sync task COMMAND_STREAM_END |
STREAM_CLEAR |
Enqueues STREAM_CLEAR into artwork/visualizer queues; for the player, signals sync task COMMAND_STREAM_CLEAR and enqueues a CHUNK_TYPE_STREAM_CLEAR_MARKER chunk into the encoded ring buffer (no player listener callback — a seek is not a stream lifecycle event) |
The JsonDocument used to parse each incoming message comes from make_json_document(). By default that allocates the document's variant pool and copied strings out of PSRAM (PsramJsonAllocator), which puts PSRAM traffic on the CPU-hot network thread for every message. When SendspinClientConfig::json_arena_size > 0 (the default is 2048), SendspinClient instead owns a SendspinArenaAllocator (a fixed internal-RAM bump arena) and process_json_message() calls reset() on it and parses into a document backed by it. An allocation that does not fit the remaining budget falls back to platform_malloc (PSRAM-preferring), so an unexpectedly large message (e.g. track metadata) still parses, just slowly.
The bump arena suits ArduinoJson's allocation pattern: during a parse the variant pool is allocated once up front and the deserializer's string scratch buffer is always the most-recently-allocated block while it grows and shrinks, so those reallocations happen in place; document teardown frees strings newest-first and the pool last (LIFO), draining the arena back to empty on its own. reset() between messages is a safety net for any arena block left behind by a non-LIFO free; it cannot free PSRAM fallbacks (those are released by deallocate() on document teardown like any other allocation). The allocator is not thread-safe; the single instance is owned by SendspinClient and touched only on the network thread (process_json_message() runs serialized on the httpd worker task, and the previous call's JsonDocument is destroyed before the next call). Outgoing-message serialization in src/protocol.cpp still uses the PSRAM allocator.
process_binary_message() extracts the type byte and routes:
| Binary Type | Handler |
|---|---|
| Player audio | PlayerRole::Impl::handle_binary(): writes to encoded audio ring buffer |
| Artwork image | ArtworkRole::Impl::handle_binary(): copies image data to a per-slot double buffer and enqueues a notification for the artwork decode thread |
| Visualizer frame/beat | VisualizerRole::Impl::handle_binary(): writes to visualizer ring buffer |
SendspinClient::loop() (src/client.cpp) runs the following steps in order on each tick:
1. connection_manager_->loop()
├─ Start WS server if network ready
├─ Swap deferred connection events under mutex
├─ Process close/disconnect events (on_connection_lost)
├─ Process hello events (handshake completion, handoff decisions)
├─ Call loop() on current and pending connections
└─ Check per-connection hello retry timers
2. time_burst_->loop(conn) (skipped when no current connection)
├─ Send next time message if ready
├─ Acquire/release high-performance networking around burst
└─ Notify listener of sync error when burst completes
3. Drain time_queue
└─ Feed time responses into time_burst_->on_time_response()
4. Role event draining (each role's impl_->drain_events())
├─ player_->impl_->drain_events()
├─ controller_->impl_->drain_events()
├─ metadata_->impl_->drain_events()
├─ color_->impl_->drain_events()
├─ artwork_->impl_->drain_events()
└─ visualizer_->impl_->drain_events()
5. Drain shadow_group
└─ Apply group deltas, fire on_group_update, persist last played server
This ordering matters: connection lifecycle events are processed before role events, and time sync before audio processing, so that roles always see a consistent connection and time state.
Each role implements drain_events() to process its deferred events on the main loop thread. This is the mechanism that converts thread-safe queue/shadow writes into sequential, single-threaded callback delivery.
Three stages, processed in order:
1. Client state updates: Drains state_queue (last value wins). Calls client_->update_state().
2. Server commands: Takes from shadow_command. Checks each field independently (volume, mute, static_delay) and fires the corresponding listener callback.
3. Stream lifecycle: The most complex part:
stream_queue → awaiting_sync_idle_events_ list
│
▼
For each event in order:
├─ STREAM_END:
│ If sync task is still running → wait for next tick
│ If sync task is idle → fire on_stream_end(), continue
│
└─ STREAM_START:
Take shadow_stream_params
Fire on_stream_start()
Signal sync task COMMAND_START
The awaiting_sync_idle_events list (on PlayerRole::Impl) is the key ordering mechanism. STREAM_END callbacks are held until the sync task has reached its IDLE state, preventing the main loop from processing a new STREAM_START before the sync task has finished with the old stream. Events ahead of the blocked event also wait, preserving FIFO order. (stream/clear is not queued here — it is handled synchronously in handle_stream_clear() by signaling the sync task and enqueuing a marker chunk.)
- ControllerRole: Takes from shadow, fires
on_controller_state(). - MetadataRole: Fires
on_metadata_clear()first if apending_clearflag is set (deferred fromcleanup()to avoid invoking the listener whileConnectionManagerholdsconn_ptr_mutex_). Then takes from shadow when the pending update'stimestamphas been reached on the synced client clock (or immediately if there is no active connection), applies deltas, fireson_metadata(). - ColorRole: Fires
on_color_clear()first if apending_clearflag is set (deferred fromcleanup()to avoid invoking the listener whileConnectionManagerholdsconn_ptr_mutex_). Then takes from shadow when the pending update'stimestamphas been reached on the synced client clock (or immediately if there is no active connection), applies deltas, fireson_color(). - ArtworkRole: Drains event queue for stream end/clear lifecycle events first (resetting all per-slot pending display timestamps and firing
on_image_clear()for each configured slot). Then iterates the per-slotDisplayScheduler::pendingshadow slots and fireson_image_display(slot)for any slot whose pending timestamp is due on the synced client clock (or immediately if there is no active connection).on_image_decodestill happens on the dedicated artwork decode thread. - VisualizerRole: Drains event queue, processes stream start/end/clear with shadow config.
The sync task (SyncTask::thread_entry, src/sync_task.cpp) runs a two-level state machine on its dedicated thread.
┌──────────────────────────────────────────────────────────┐
│ COMMAND_STOP? │
│ ┌─── yes ──→ exit thread │
│ │ │
│ ┌─────────────────┴──────────────────┐ │
│ │ IDLE STATE │ │
│ │ • Clear TASK_RUNNING and all │ │
│ │ COMMAND flags │ │
│ │ • Set TASK_IDLE │ │
│ │ • Reset context + progress queue │ │
│ │ • Wait for codec header (500ms) │◄──┐ │
│ └────────────┬───────────────────────┘ │ │
│ │ got header │ │
│ ▼ │ │
│ ┌────────────────────────────────────┐ │ │
│ │ WAIT FOR CLIENT ACK │ │ │
│ │ • Wait on COMMAND_START or │ │ │
│ │ STOP/END/CLEAR │ │ │
│ │ • If END/CLEAR arrives, return │───┘ │
│ │ header to buffer and loop back │ │
│ └────────────┬───────────────────────┘ │
│ │ COMMAND_START │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ ACTIVE STATE │ │
│ │ • Clear TASK_IDLE, COMMAND_START │ │
│ │ • Drain stale playback progress │ │
│ │ • Set TASK_RUNNING │ │
│ │ • Enqueue SYNCHRONIZED state │ │
│ │ • Decode initial codec header │ │
│ │ • Run inner state machine loop │ │
│ └────────────┬───────────────────────┘ │
│ │ STOP/END/CLEAR │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ Return borrowed ring buffer entry │──────→ loop back │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
The WAIT FOR CLIENT ACK step is critical. Without it, the sync task could race from IDLE back to ACTIVE so fast that the main loop never observes TASK_IDLE, and the awaiting_sync_idle_events_ mechanism in PlayerRole::drain_events() would deadlock waiting for an idle transition that already passed.
INITIAL_SYNC ──→ LOAD_CHUNK ──→ SYNCHRONIZE_AUDIO ──→ TRANSFER_AUDIO
│ ▲ │ │
│ └───────────────┴───────────────────────┘
│ (cycle per chunk)
└──→ LOAD_CHUNK (once first playback progress callback confirms frames were consumed)
INITIAL_SYNC: Fills the audio pipeline with silence to prime DMA buffers. Sleeps briefly after sending to let the audio stack start consuming. Once the first playback-progress callback confirms frames were consumed, it queues extra_startup_silence_ms of additional silence (see PlayerRoleConfig) and drains it before advancing to LOAD_CHUNK. This extra lead gives the decode pipeline slack to stay ahead of the sink at stream start, preventing the initial-playback stutter caused by the decoder briefly falling behind.
LOAD_CHUNK: Reads the next encoded chunk from the ring buffer. Waits for time sync if not yet available. Decodes audio via FLAC/Opus/PCM decoder. On a ring-buffer underflow (no chunk ready) while still aligning (startup or post-seek), it feeds silence toward the sink to keep the DAC fed while the decode pipeline catches up, instead of letting it run dry; SYNCHRONIZE_AUDIO then re-aligns the next chunk against wherever the silence carried us. In steady state it does not fill — an empty buffer there means the stream is winding down, and stuffing silence would pile up in the sink and delay a rapid restart (a genuine underrun instead surfaces as an error in SYNCHRONIZE_AUDIO).
SYNCHRONIZE_AUDIO: Computes the sync error:
error = decoded_timestamp - new_audio_client_playtimeWhere decoded_timestamp is the server timestamp converted to client time (via Kalman filter) minus static and fixed delays, and new_audio_client_playtime is the predicted time that the next audio will actually play.
| Error Range | Action |
|---|---|
| > +5000 us (or +500 us settling) | Hard sync ahead: insert silence frames to fill the gap |
| < -5000 us (or -500 us settling) | Hard sync behind: drop the decoded chunk |
| +100 to +5000 us | Soft sync: insert one interpolated frame near the end (average of last two) |
| -100 to -5000 us | Soft sync: remove last frame (blend into second-to-last) |
| -100 to +100 us | Dead zone: pass audio through unmodified |
Hard sync sets a flag that switches to a tighter 500 us settle threshold until the error is small enough to exit hard sync mode.
TRANSFER_AUDIO: Writes PCM data to the audio sink via on_audio_write. If silence was inserted (hard sync ahead), transfers silence first, then re-enters SYNCHRONIZE_AUDIO for the held-back decoded data.
The audio output hardware reports consumed frames via notify_audio_played() → playback_progress_slot_ (a ShadowSlot whose merge strategy sums frames_played across unread updates and keeps the latest finish_timestamp). The sync task takes the accumulated value on every inner loop iteration to maintain an accurate new_audio_client_playtime estimate:
new_audio_client_playtime = last_finish_timestamp + remaining_buffered_frames_as_microsecondsThis feedback loop is what makes the sync error calculation accurate.
Time sync uses a burst-based NTP-style protocol:
- Send 8 time request messages per burst (each with a 10-second response timeout).
- Wait 10 seconds between bursts.
- Select the measurement with the lowest round-trip time (lowest
max_error). - Feed the best measurement into the Kalman filter.
High-performance networking (e.g., disabling WiFi power saving) is acquired for the duration of a burst and released when complete.
Two-dimensional state vector: [offset, drift].
- First measurement establishes the offset baseline.
- Second measurement estimates initial drift from finite differences.
- Subsequent measurements: predict offset forward by
drift * dt, then correct using the new measurement. - Adaptive forgetting: if the residual exceeds
2.0 * max_error, the covariance is inflated by a forgetting factor (1.1) to recover from step changes. - Drift compensation is only enabled after 100 samples and only when drift significance exceeds its noise floor.
The filter is protected by state_mutex_ so that compute_client_time() can be called from the sync task thread while update() runs from the main loop thread.
The ConnectionManager maintains two observer slots for the connections it routes messages through:
| Slot | Purpose |
|---|---|
current_connection_ |
Active connection receiving messages |
pending_connection_ |
Handoff candidate completing its handshake |
Both are std::shared_ptr<SendspinConnection>, and on the ESP server path they are observers rather than authoritative owners — the authoritative owner of a SendspinServerConnection is the httpd session itself (see Server connection ownership (ESP)). On the host (IXWebSocket) client path the shared_ptr in these slots is the only owner.
- A new connection (outbound or inbound) is started. If a current connection exists, the new one becomes
pending_connection_. - The connection sends a CLIENT_HELLO. Retry with exponential backoff (100 ms base, 3 attempts). Each managed connection has its own retry entry in
ConnectionManager::hello_retries_, so a handoff candidate arriving mid-handshake cannot clobber the current connection's pending hello (and vice versa). - SERVER_HELLO arrives on the network thread → enqueued into mutex-protected vector.
- Main loop processes the hello event:
- Stores server info on the connection.
- If this was the pending connection, runs handoff decision logic:
- Prefer PLAYBACK reason over DISCOVERY.
- Among two DISCOVERY connections, prefer the last-played server.
- Default: keep current.
- Handoff executes: disable old message dispatch → cleanup state → move connections → send goodbye to the rejected connection.
When a connection is lost (on_connection_lost):
1. conn->disable_message_dispatch() ← atomic, immediate on network thread
2. time_burst_->reset() ← stop time sync
3. client_->cleanup_connection_state() ← drain all role queues/shadows, signal stream end
4. current_connection_.reset() ← destroy connection
5. Promote pending to current if exists
disable_message_dispatch() is the first step because it's an atomic flag that the network thread checks before invoking any callback. This prevents stale messages from a dead connection from racing into freshly-reset role queues.
disconnect_and_release() calls conn->disconnect(reason, nullptr) and lets the local shared_ptr go out of scope.
- ESP server: the goodbye text is queued as an httpd worker job. The worker resolves the connection by
lock()ing theweak_ptrcaptured in the queued arg when the goodbye was enqueued; if it resolves it sends the frame, then runs the completion lambda that callstrigger_close(). The session slot installed inopen_callbackkeeps the connection alive across that whole sequence even afterConnectionManager's observershared_ptris dropped. The session is finally freed when httpd invokes the slot'sfree_fn(see Server connection ownership (ESP)). The completion lambda also captures aweak_ptrto make this lifetime explicit —trigger_close()is skipped if the conn has already been freed. Goodbye is one of the two messages that passallow_before_hello=true, so it is not blocked by the pre-hello send gate (a rejected connection is told to leave before it ever sends a hello). - Host client: the IXWebSocket send is synchronous, so the goodbye and close have both completed by the time
disconnect()returns and theshared_ptrdrops the last reference.
On the ESP build, SendspinServerConnection lifetime is pinned to the httpd session rather than to ConnectionManager:
SendspinWsServer::open_callback(the httpdopen_fn) creates theshared_ptr<SendspinServerConnection>, heap-allocates ashared_ptr*slot, and callshttpd_sess_set_ctx(handle, sockfd, slot, free_fn)with a deleter thatdeletes the slot. That slot is the authoritative reference.- The same shared_ptr is forwarded into
ConnectionManager::on_new_connection, which stores it incurrent_connection_orpending_connection_as a secondary observer. - The httpd WebSocket handler (
websocket_handler) looks the connection up byhttpd_sess_get_ctx(handle, sockfd)at run time, copying the slot'sshared_ptrfor the duration of its work; it never assumes the manager's observer slot is alive. The queued send workers (async_send_text,async_send_time_text) instead capture aweak_ptr<SendspinServerConnection>to the originating connection andlock()it when they run. - When the socket closes, httpd calls the
close_fnfirst (which firesconnection_closed_callback_soConnectionManagercan drop its observer in the nextloop()), then later calls the slot'sfree_fnto release the authoritative reference once no workers are queued for that session.
Queued send workers capture a weak_ptr<SendspinServerConnection> to the originating connection — AsyncRespArg for text sends, SessionLookup for time sends — and lock() it when the worker runs. This is deliberately not a {httpd_handle_t, int sockfd} pair: identifying the target by sockfd risked binding to a different connection that had recycled the same fd after the original closed, sending a frame to the wrong peer. The weak_ptr resolves to the exact connection that queued the work, or to null if it has since been destroyed, in which case the worker no-ops cleanly. Because these structs now hold non-trivial members (the weak_ptr, and AsyncRespArg's completion std::function), they are constructed with placement-new and explicitly destroyed before platform_free rather than treated as POD. Both are allocated through platform_malloc / platform_malloc_internal.
The send workers also enforce the protocol's "hello is always first" rule: a frame is dropped unless client_hello_sent_ is set on the resolved connection, unless the caller passed allow_before_hello=true. Exactly two callers do — the client/hello itself (which would otherwise gate its own send and deadlock) and goodbye — so a stale or out-of-order frame can never precede the handshake. The weak_ptr guards identity; the gate guards ordering; the two are independent.
The host build does not need this scheme: SendspinWsServer (host) routes IXWebSocket messages by calling find_connection_callback_ to resolve a synthetic sockfd back to the connection that ConnectionManager is holding. The ESP build keeps the set_find_connection_callback() setter as a no-op stub for symmetry; see the comment at the call site in ConnectionManager::init_server.
All network thread actions are deferred to the main loop via queues, shadow slots, or mutex-protected vectors. The main loop processes them in a fixed order each tick (connections → time → roles → group). This guarantees that:
- Connection state is settled before roles process events.
- Time sync is updated before audio sync decisions.
- Role events fire in FIFO order per role.
The combination of awaiting_sync_idle_events_ (main loop) and COMMAND_START (sync task wait) creates a two-way handshake:
- Network thread enqueues STREAM_END → STREAM_START into
stream_queue. - Sync task receives
COMMAND_STREAM_END, finishes active stream, enters IDLE, setsTASK_IDLE. - Main loop sees STREAM_END in queue, checks
is_running()→ false (idle), fireson_stream_end(). - Main loop sees STREAM_START, fires
on_stream_start(), signalsCOMMAND_START. - Sync task receives
COMMAND_START, exits wait, enters ACTIVE.
This prevents the sync task from starting a new stream before the main loop has processed the end of the old one.
Audio output callbacks run on a platform audio thread. They report consumed frames via notify_audio_played() → playback_progress_slot_ (merging sums frames and keeps the latest timestamp). The sync task takes the accumulated value non-blockingly on each iteration of its inner loop, keeping the playtime estimate accurate without blocking the audio thread.
disable_message_dispatch() + queue draining + event flag signaling ensures that after cleanup:
- No new messages will be delivered from the old connection.
- All pending events are discarded.
- The sync task is signaled to end its current stream.
- The main loop will process the synthetic STREAM_END on its next tick.