Repository navigation
Document that callbacks never call start() or stop() - #262
Merged
Merged
Conversation
No listener, role or provider callback calls start() or stop(), as no callback calls loop(). With all three off-limits no drain runs beneath another and no role's main-loop teardown half runs during a callback, so the machinery that kept an outer drain from acting on what a nested stop() had already torn down goes: EventState::drain_generation and its checks, the pairing-notes move-out, start()'s STOPPING branch, the post-callback and drain-head accepts() checks with RoleTeardown::accepts() itself, TeardownTracker::completed() and metadata's check before the deprecated on_metadata(), and the player's indexed loop and clamp. The protocol task's own teardowns stay caught by the generation stamps.
kahrendt
force-pushed
the
callbacks-never-stop-start
branch
from
November 11, 2026 19:45
fff904e to
fa0c998
Compare
kahrendt
marked this pull request as ready for review
November 11, 2026 19:51
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
A critical race allows an old START to discard a newer stream’s parameters after protocol-task teardown, causing the next stream-start callback to read stale parameters.
3 open issues
What changed in this PR
Extends #259’s callback contract to forbid calling start() or stop() from callbacks. Consumers must defer stop or restart requests until between loop() calls, allowing the client and role drains to remove nested-lifecycle handling.
Changes:
- Documents the callback restrictions and deferred lifecycle-request pattern.
- Removes re-entrancy guards and nested-drain buffer handling.
- Revises lifecycle tests and documentation to match the new contract.
| File | Description |
|---|---|
tests/test_source_role.cpp |
Removes callback-triggered stop coverage. |
tests/test_encrypted_lifecycle.cpp |
Removes stop-from-callback listener cases. |
tests/test_client_lifecycle.cpp |
Revises stopped-state checks and removes re-entrant lifecycle cases. |
tests/README.md |
Updates the lifecycle test summary. |
tests/CMakeLists.txt |
Updates white-box test access explanations. |
src/teardown_tracker.h |
Removes callback re-entry checks and unused accessors. |
src/source_role.cpp |
Removes obsolete re-entrant teardown comments. |
src/scheduled_state_slot.h |
Removes the callback re-entry guard. |
src/player_role.cpp |
Simplifies stream-event iteration and removes generation guards. |
src/metadata_role.cpp |
Removes the teardown check between listener forms. |
src/metadata_role_impl.h |
Updates callback-dispatch documentation. |
src/controller_role.cpp |
Removes the drain’s additional generation check. |
src/client.cpp |
Removes nested-drain guards and simplifies pairing dispatch. |
src/artwork_role.cpp |
Removes callback-related generation guards. |
include/sendspin/client.h |
Documents lifecycle callback restrictions. |
docs/internals.md |
Revises dispatch, shutdown, and allocation descriptions. |
docs/integration-guide.md |
Documents deferred stop/restart requests and callback delivery. |
docs/conventions.md |
Replaces re-entrant teardown requirements with callback restrictions. |
.claude/skills/house-patterns/SKILL.md |
Aligns the review checklist with the new contract. |
Review effort: Max
A STREAM_START the player drain processes after an earlier callback took whatever stream parameters were pending and dropped them on a stamp mismatch. A protocol-task teardown during that callback, followed by the next connection's stream/start, left the next START with no parameters, so its on_stream_start() read the previous stream's. 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. Also documents that a teardown during a callback can be caught up later in the same drain, and asserts that connect_to() and disconnect() from a callback inside stop() post no request.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
No listener, role or provider callback may call
start()orstop(), the same rule #259 set forloop()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 twoloop()calls, which is what the ESPHome hub already does: it callsstop()/start()only from its ownloop()andon_shutdown().Change
Removed, each of which only served a callback that calls
stop()(and maybestart()) beneath the drain that fired it:EventState::drain_generation, its bump instop(), and every check indrain_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 deprecatedon_group_update().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 insidestop()could reach. The STOPPING state stays: other threads, andconnect_to()/disconnect()from callbacks insidestop(), still needis_started()to read false.accepts(generation)checks after player, artwork and controller callbacks and at the head of their drains anddrain_scheduled_state(), thenRoleTeardown::accepts()itself.stop().teardown_reenteredand the erase clamp. The awaiting events change only from the ring drain and the catch-up, neither of which can run inside the loop.TeardownTracker::completed()check before the deprecatedon_metadata(), andcompleted()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 newGenerationSlot::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", plusstop()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
StopFromInsideADrainCallbackIsSafe,StopFromInsideAPairingCallbackDismissesThePromptOnceandStopFromTheStoppedCallbackFindsTheHoldReleased.MetadataSlot, the "stop() from ..." rows from the metadata and group listener-form tables, and the "stop() from the first view form" row fromPairingCallbacksReachBothForms.CallbackDuringStopCannotRecursebecomesCallbackDuringStopSeesTheStoppedClient: it keeps the stopped-state reads and now checks the request slot, so aconnect_to()ordisconnect()the callback posted fails it (mutant: dropconnect_to()'s running check).TeardownReorder.AStreamStartTakesOnlyItsOwnStreamParams: a teardown plus the next stream'sstream/startduringon_volume_changed(), with a control row. A mutant restoring the unconditional take makes the next stream'son_stream_start()read no sample rate.on_track_metadata()stays as the control: the deprecated form still follows.tests/esp_idfcompiles the library without warnings.