Files
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andTyler Longwell 6e7683f3e6 feat(relay/audio): non-owner huddle teardown reader (§4, reader half)
Implements the dependency-free half of §4 (huddle lifecycle): the non-owner
side reacts to the owner's teardown signal read off the `HuddleControl` stream
and tears the local client down so it can rejoin. This is the piece Wren's gate
names — it reacts to wire signals only and needs no huddle-lane renewer (the
owner-side proactive-loss `Goodbye` producer is the separate next increment).

Wire mapping (no `Lost` variant exists; the enum is SessionEnded/Draining/
StaleGeneration): owner-loss surfaces as `Goodbye(StaleGeneration)` -> OwnerLost;
`Goodbye(Draining)` -> OwnerDraining; `Goodbye(SessionEnded)` -> ordinary
disconnect; a bare stream close/reset (owner pod died mid-flight) -> StreamClosed.
Every cause is recoverable by a rejoin, so behaviour is uniform (cancel + forget);
the cause is observability only.

join.rs:
- New `HuddleTeardownCause` + `read_teardown_cause(&mut MeshStream)`: a pure,
  testable reader that returns on the first terminal signal and skips
  non-terminal owner->client frames (the forward-compat seam for future roster
  deltas, which must not be mistaken for teardown).
- `RemoteHuddleSession` no longer owns the control stream — it holds only the
  outbound (client->owner) half. `dial_remote_owner` now returns
  `(RemoteHuddleSession, MeshStream)`: the session forwards media, the stream is
  handed to the reader task. `close()` is replaced by a free
  `send_clean_close(stream, fenced, pubkey)` so the reader task owns the stream
  end-to-end (one owner, one closer).

handler.rs:
- Spawn a reader task that races `read_teardown_cause` against the connection's
  cancel token. Owner speaks first -> `teardown_remote_huddle`: cancel the
  connection (drives the client's WS Close = rejoin) + forget the local
  generation floor for this session. We cancel first (client left / heartbeat
  death) -> `send_clean_close` so the owner drops us. The task is joined during
  cleanup so its close completes before the connection returns.

mesh_boot.rs:
- Share one `Arc<GenerationFloor>` on `MeshHandle` (`audio_fence`) so the
  datagram receive path (`MeshAudioRouter`, wired via the pending `register_*`)
  and this teardown path consult and clear ONE floor. A private floor per
  consumer would let a torn-down session keep suppressing a rejoin. The floor
  stays a local stale-frame guard only: `forget` clears local suppression so a
  rejoin's fresh generation is accepted; Redis fenced CAS remains the ownership
  arbiter and `forget` never authorizes ownership (per Perci's invariant).

Adds six tests over the existing in-memory MeshStream pair: each `GoodbyeReason`
maps to its cause, a bare stream close maps to StreamClosed, a non-terminal Data
frame is skipped before a later Goodbye, and the clean close emits
UnregisterPeer then Goodbye(SessionEnded) in order. fmt + clippy clean; 519
buzz-relay tests pass.

Scoped out (next increment, Wren-gated): the huddle-lane renewer + owner-side
proactive-loss `Goodbye(StaleGeneration)` producer. This commit reacts to that
signal; it does not yet produce it.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-08 14:50:38 -04:00
..