Skip to content

Hand pairing text, tokens and server information to consumers without copies - #261

Merged
kahrendt merged 3 commits into
mainfrom
view-api-pairing
Nov 11, 2026
Merged

kahrendt merged 3 commits into
mainfrom
view-api-pairing

Conversation

@kahrendt

@kahrendt kahrendt commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Second step of moving the public API from std::string to std::string_view (after #260). The pairing callbacks built a std::string per call, and the pairing token and server information getters built new strings on every call, all through operator new, which aborts on ESPHome. The new forms hand over views or a fixed-size array; the string forms are deprecated and go in v0.11.0. Part of Sendspin/backlog#92.

Migration:

Deprecated Replacement
on_pairing_started(const std::string&) on_pairing_attempt(std::string_view)
on_pairing_succeeded(const std::string&) on_paired(std::string_view)
on_pairing_failed(const std::string&, reason) on_pairing_aborted(std::string_view, reason)
on_display_pairing_code(const std::string&, format) on_pairing_code(std::string_view, format)
pairing_token(), format_pairing_token(psk) client_pairing_token(), pairing_token_for(psk): std::optional<std::array<char, PAIRING_TOKEN_LENGTH>>
get_server_information() (ServerInformationObject) server_information() (ServerInformation), main loop only

Callback views are valid for the callback; server_information()'s until control next returns to loop() or stop(), like group_state(). It can trail is_connected() by one loop(), so read it on each loop() rather than only when is_connected() turns true. Code off the main loop has no replacement for get_server_information() and keeps calling it until v0.11.0. The token array carries no NUL. Overriding a deprecated pairing callback draws no deprecation warning (their parameter types are not deprecated), so search for the four old names, and mark overrides override so their removal in v0.11.0 fails to compile.

Change

  • Dispatch: each new pairing callback fires first, then its deprecated form, which is skipped after a re-entrant stop(). The deprecated forms fill a string reserved at construction rather than building one per call, so they no longer allocate either.
  • Server information: ConnectionManager publishes the primary's server through a new Inbox slot only when the primary changes (about 115 bytes then, nothing per loop()); drain_inbox() takes it before any callback, and stop() clears the copy.
  • Tokens: PAIRING_TOKEN_LENGTH (107) is a public constant, checked against the internal encoder's size.
  • A scoped pragma in client.h wraps the deprecated get_server_information() declaration: GCC otherwise warned in every file including the header, because the declaration names the deprecated type.
  • The examples and the integration guide (pairing sections, Querying State, the Deprecated String API table) use the new API.

Tests

  • ClientLifecycle.PairingCallbacksReachBothForms: only new, only deprecated, both (new first) and a stop() from the new form, across every abort reason and both code formats; the pairing state-machine suite checks every text callback is followed by its deprecated form with the same content.
  • Token getters: nullopt before start(), equal to the encoder over the client's identity (configured and stored PSK) and to the deprecated getters.
  • server_information() matches the deprecated getter across restart, re-handshake, pairing, outbound, a liveness drop and a two-server handoff, and is cleared by stop().
  • Alloc windows: the pairing_psk and code-pairing windows (digits and QR) drop from 2, 2 and 3 operator new calls to 0 with listeners overriding both forms; admission and the playback handoff cover the server-information refresh at 0. Mutants caught include a skipped or reordered deprecated call, the stop() skip removed, views into the deprecated string, an unreserved compat string and a copy never refreshed or cleared.
  • Builds checked: -DENABLE_WERROR=ON with examples, and an ESP-IDF esp32s3 build with no deprecation warnings. Protocol task stack bound unchanged (6,512 B at -Os).

… copies

on_pairing_attempt(), on_paired(), on_pairing_aborted() and
on_pairing_code() deliver their text as std::string_view into the
pairing note being dispatched; client_pairing_token() and
pairing_token_for() return the 107-character token as a std::array by
value; and server_information() returns views into a main-loop copy of
the primary connection's server, refreshed through the Inbox only when
the primary changes.

The std::string forms (on_pairing_started(), on_pairing_succeeded(),
on_pairing_failed(), on_display_pairing_code(), pairing_token(),
format_pairing_token(), get_server_information() and
ServerInformationObject) are deprecated and go in v0.11.0. The library
calls each new callback first and then its deprecated form, skipping it
after a re-entrant stop(), and the deprecated pairing callbacks now fill
a string reserved at construction rather than building one per call.
@kahrendt
kahrendt marked this pull request as ready for review November 11, 2026 18:40
@kahrendt
kahrendt requested a max review from Copilot November 11, 2026 18:40
@kahrendt kahrendt added the enhancement New feature or request label Nov 11, 2026
@kahrendt
kahrendt marked this pull request as draft November 11, 2026 18:47
server_information() and is_connected() now say the copy can trail the
connected flag by one loop(), so it is read on each loop() rather than on
the rising edge, and the drain comment says what its callbacks see. The
protocol task hands the server to its slot in place through update(),
with a test for two primary changes between drains. The pairing tests
drop a pointer check no contract needs and the TearDown cross-check that
duplicated PairingCallbacksReachBothForms.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Teardown callbacks can observe stale primary-server state, and stopping inside deprecated pairing callbacks lacks regression coverage.

4 open issues
What changed in this PR

Continues #260’s allocation-free public API migration in sendspin-cpp. Pairing callbacks and server information gain view-based APIs, while pairing tokens use fixed-size arrays; deprecated string APIs remain available until v0.11.0.

Changes:

  • Adds pairing text views, token arrays, and an Inbox-backed server-information snapshot for the main loop.
  • Retains compatibility callbacks with reserved string storage and re-entrant stop() handling.
  • Migrates examples and documentation, and expands lifecycle, compatibility, and allocation checks.
File Description
tests/​test_record_store.cpp Checks new and deprecated token getters.
tests/​test_pairing_state_machine.cpp Checks callback order and matching content.
tests/​test_encrypted_lifecycle.cpp Uses new callbacks and server snapshots.
tests/​test_connection_lifecycle.cpp Checks snapshots through admission, handoff, and drops.
tests/​test_client_lifecycle.cpp Adds callback-form and re-entrant stop cases.
tests/​test_allocations.cpp Pins pairing allocations at zero and checks snapshot refreshes.
tests/​README.md Documents pairing callback coverage.
tests/​lifecycle_test_fixtures.h Adds a snapshot-refresh and compatibility helper.
tests/​CMakeLists.txt Documents additional private accesses used by tests.
src/​record_store.cpp Updates the token-getter reference in a comment.
src/​inbox.h Adds the server-information topic.
src/​connection.h Updates the pairing callback reference in a comment.
src/​connection_manager.h Makes inline published server data available internally.
src/​connection_manager.cpp Publishes server changes to the Inbox.
src/​client.cpp Implements new getters, snapshots, and compatibility dispatch.
src/​client_dispatch.cpp Updates the persistence-ordering callback comment.
include/​sendspin/​types.h Adds ServerInformation and deprecates its string counterpart.
include/​sendspin/​config.h Updates callback and token-getter documentation.
include/​sendspin/​client.h Declares new APIs, lifetimes, and deprecations.
examples/​tui_client/​main.cpp Migrates pairing callbacks and token storage.
examples/​source_client/​main.cpp Uses new pairing APIs and length-aware printing.
examples/​basic_client/​main.cpp Uses new pairing APIs and length-aware printing.
docs/​internals.md Describes snapshot delivery and compatibility storage.
docs/​integration-guide.md Updates migration guidance, usage, and threading contracts.
docs/​conventions.md Extends view-storage conventions to pairing and server text.

Review effort: Max

Comment thread src/client.cpp Resolved
Comment thread tests/test_client_lifecycle.cpp Resolved
Comment thread docs/integration-guide.md Resolved
Comment thread tests/test_record_store.cpp Resolved
drain_inbox() takes the server information before the catch-up fires a
teardown's clears, so a clear reads the server the same tick left, with
a test that fails the other way round. The migration notes say that
overriding a deprecated callback draws no warning, and the token test
reads the client's key from client_id() rather than a private member.
@kahrendt
kahrendt marked this pull request as ready for review November 11, 2026 19:28
@kahrendt
kahrendt requested a balanced review from Copilot November 11, 2026 19:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues are identified for the allocation-reduction and server-information changes.

No open issues 🎉

4 resolved since last review

Review effort: Balanced

@kahrendt
kahrendt merged commit ff02b43 into main Nov 11, 2026
17 checks passed
@kahrendt
kahrendt deleted the view-api-pairing branch November 11, 2026 19:39
kahrendt added a commit that referenced this pull request Nov 11, 2026
## Summary

No listener, role or provider callback may call `start()` or `stop()`,
the same rule #259 set for `loop()` and enforced the same way
(documentation, no runtime check). With all three off-limits, no drain
ever runs beneath another and no role's main-loop teardown half runs
while one of its callbacks is on the stack. That removes the only reason
for the re-entrancy guards in the drain, so they go. A consumer that
wants to stop or restart in response to a callback records the request
and acts on it between two `loop()` calls, which is what the ESPHome hub
already does: it calls `stop()`/`start()` only from its own `loop()` and
`on_shutdown()`.

## Change

Removed, each of which only served a callback that calls `stop()` (and
maybe `start()`) beneath the drain that fired it:

- `EventState::drain_generation`, its bump in `stop()`, and every check
in `drain_inbox()`: the event-ring skip and per-event abort, the
pairing-note checks, the gates before each role drain and the group
drain, and the check before the deprecated `on_group_update()`.
- The pairing notes' move-out-and-back plus the `reserve()` a nested
drain needed. The drain uses its buffer in place. The internals Memory
row for the extra buffer goes too.
- `start()`'s STOPPING branch and its warning, which only a callback
inside `stop()` could reach. The STOPPING state stays: other threads,
and `connect_to()`/`disconnect()` from callbacks inside `stop()`, still
need `is_started()` to read false.
- The `accepts(generation)` checks after player, artwork and controller
callbacks and at the head of their drains and `drain_scheduled_state()`,
then `RoleTeardown::accepts()` itself.
- Between a drain's catch-up and those head checks only the clear
callback runs, so they only caught a callback calling `stop()`.
- A protocol-task teardown in that window now gives "state, then clear
on the next drain". That is the outcome it already gave when it landed
just after the check.
- The player's indexed loop, `teardown_reentered` and the erase clamp.
The awaiting events change only from the ring drain and the catch-up,
neither of which can run inside the loop.
- Metadata's `TeardownTracker::completed()` check before the deprecated
`on_metadata()`, and `completed()` itself.

The protocol task's teardowns stay caught by the generation stamps
(`keep_current_payload()`, `event_is_current()`, `ScheduledStateSlot`'s
sequence check), which are unchanged.

One take did depend on the removed checks: the player's STREAM_START
took whatever stream parameters were pending and dropped them on a stamp
mismatch. A teardown during an earlier callback in the same drain, plus
the next connection's `stream/start`, left the next START reading the
previous stream's parameters. The new `GenerationSlot::take_stamped()`
takes a payload only under the given stamp and leaves a later one
pending. The player takes its parameters under the START's stamp, and
the visualizer its config the same way.

Docs: `client.h` (`start()`, `stop()`, `LifecycleState`), integration
guide (Stopping and Restarting, Thread Safety Summary, string_view
section), conventions.md, the house-patterns checklist, and internals.md
(the re-entrant section becomes "Callbacks During Dispatch", plus
`stop()` steps 0 and 6).

Also removed, from what #261 added for pairing: the skip before each
deprecated pairing callback and the nested-drain comments on its text
buffer.

## Tests

- Deleted `StopFromInsideADrainCallbackIsSafe`,
`StopFromInsideAPairingCallbackDismissesThePromptOnce` and
`StopFromTheStoppedCallbackFindsTheHoldReleased`.
- Dropped the stop/start rows from `MetadataSlot`, the "stop() from ..."
rows from the metadata and group listener-form tables, and the "stop()
from the first view form" row from `PairingCallbacksReachBothForms`.
- `CallbackDuringStopCannotRecurse` becomes
`CallbackDuringStopSeesTheStoppedClient`: it keeps the stopped-state
reads and now checks the request slot, so a `connect_to()` or
`disconnect()` the callback posted fails it (mutant: drop
`connect_to()`'s running check).
- New `TeardownReorder.AStreamStartTakesOnlyItsOwnStreamParams`: a
teardown plus the next stream's `stream/start` during
`on_volume_changed()`, with a control row. A mutant restoring the
unconditional take makes the next stream's `on_stream_start()` read no
sample rate.
- The metadata-form row where the protocol task tears down during
`on_track_metadata()` stays as the control: the deprecated form still
follows.
- ASan, TSan and the allocation tests run clean, and an ESP-IDF 5.5
esp32s3 build of `tests/esp_idf` compiles the library without warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants