Skip to content

Document that callbacks never call loop() - #259

Merged
kahrendt merged 1 commit into
mainfrom
callbacks-no-loop
Nov 11, 2026
Merged

kahrendt merged 1 commit into
mainfrom
callbacks-no-loop

Conversation

@kahrendt

Copy link
Copy Markdown
Contributor

Summary

Documents that no callback may call loop(). Every main-loop callback already runs inside one of the client's own calls (loop(), start(), stop() or its destructor), and loop() does not support being re-entered. The scheduled-state drain from #258 relies on this: a callback that re-entered loop() between its peek and commit could commit a stale decision. Closes #257.

Change

  • include/sendspin/client.h: the loop() doc states the rule and points at the integration guide.
  • docs/integration-guide.md, Thread Safety Summary: the rule and why, including callbacks on other threads.
  • docs/conventions.md: the re-entrant-teardown rule names loop() as the one client method a callback does not call.
  • docs/internals.md, "Re-entrant Teardown During Callback Dispatch": the only drain nested beneath another is the one stop() runs.

Tests

Documentation only; no behaviour changes, and nothing enforces the rule at runtime (conventions.md "Contracts are documented and enforced by documentation, not by defensive code").

loop() runs every main-loop callback and does not support being
re-entered, so the public loop() doc, the integration guide's Thread
Safety Summary and conventions.md now say no listener, role or provider
callback calls it, and internals.md notes that the only drain nested
beneath another is the one stop() runs.

Closes #257.
@kahrendt
kahrendt marked this pull request as ready for review November 11, 2026 13:18
@kahrendt
kahrendt requested a balanced review from Copilot November 11, 2026 13:18

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 were identified for this documentation-only clarification of callback restrictions.

No open issues 🎉

What changed in this PR

This documentation-only PR clarifies that callbacks must never call SendspinClient::loop(), addressing #257 and documenting the non-reentrancy contract relied on by the scheduled-state drain from #258. No runtime behavior changes.

Changes:

  • State the callback restriction in the public API and integration guide.
  • Clarify that re-entrant teardown remains supported, with stop() providing the only nested drain.
File Description
include/​sendspin/​client.h Documents the callback restriction on loop().
docs/​internals.md Clarifies why only stop() can run a nested drain.
docs/​integration-guide.md Explains the restriction for main-loop and background callbacks.
docs/​conventions.md Excludes loop() from permitted callback re-entry.

Review effort: Balanced

@kahrendt
kahrendt merged commit 1a50818 into main Nov 11, 2026
17 checks passed
@kahrendt
kahrendt deleted the callbacks-no-loop branch November 11, 2026 13:29
@kahrendt kahrendt added the documentation Improvements or additions to documentation label Nov 11, 2026
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

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document callbacks shouldn't call loop()

2 participants