diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 06d3a32b43d..bebae0cec3c 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -60,6 +60,11 @@ const MAX_MISSED_PONGS: u8 = 3; /// Auth timeout. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); +/// How long an emptied huddle room stays open for a rejoin before it +/// auto-ends. Covers a laptop sleep/wake or a client-side reconnect without +/// ending the huddle for everyone who was about to come back. +const ROOM_EMPTY_GRACE: Duration = Duration::from_secs(20); + /// WebSocket upgrade handler for `/huddle/:channel_id/audio`. pub async fn ws_audio_handler( State(state): State>, @@ -792,6 +797,10 @@ async fn handle_active_audio_connection( // the local generation floor so the fresh generation is accepted. The cause // distinction is carried on the remote control streams; locally the action // is the same WS teardown. Silent on ordinary client leave. + // Clones survive the teardown watcher's move so the leave path below can + // tell a drain-driven teardown from an ordinary leave. + let owner_lost_after_leave = owner_lost.clone(); + let owner_draining_after_leave = owner_draining.clone(); let owner_teardown_task = if owner_lost.is_some() || owner_draining.is_some() { let fence = Arc::clone( &state @@ -864,14 +873,15 @@ async fn handle_active_audio_connection( let _ = owner_teardown_task.await; } - // Atomic owner remove + end check: remove_peer_and_check_ended holds the - // AdmissionGuard lock across index recycling AND the is_empty + ended=true - // check. Ingress mirrors never archive authoritative huddle state; they + // Atomic owner remove + idle observation: remove_peer_and_check_idle holds + // the AdmissionGuard lock across index recycling AND the is_empty check, + // capturing the admission generation so a later end_if_idle cannot race a + // rejoin. Ingress mirrors never archive authoritative huddle state; they // remove locally and let the owner decide room lifetime. let removal = if remote_session.is_some() { - room.remove_peer(peer_id).map(|delta| (delta, false)) + room.remove_peer(peer_id).map(|delta| (delta, None)) } else { - room.remove_peer_and_check_ended(peer_id) + room.remove_peer_and_check_idle(peer_id) }; let removal_revision = if remote_session.is_none() { removal.as_ref().map(|(delta, _)| delta.revision) @@ -880,7 +890,7 @@ async fn handle_active_audio_connection( // ordering. Omit it rather than publishing a plausible-but-wrong value. None }; - let should_auto_end = removal.as_ref().map(|(_, ended)| *ended).unwrap_or(false); + let idle = removal.as_ref().and_then(|(_, idle)| *idle); if remote_session.is_none() { if let Some((delta, _)) = removal { @@ -918,45 +928,51 @@ async fn handle_active_audio_connection( ) .await; - let room_emptied; - if should_auto_end { - info!(channel_id = %channel_id, "audio room empty — auto-ending huddle"); - - match state - .db - .archive_channel(tenant.community(), channel_id) - .await - { - Err(e) => { - warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}"); - room.clear_ended(); - room_emptied = false; - } - Ok(()) => { - room_emptied = state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); - - emit_participant_event( - &state, - &tenant, - channel_id, - parent_id_for_event, - ParticipantLifecycle { - kind: Kind::Custom(48103), - participant_pubkey: &pubkey_hex, - roster_revision: None, - admission_id: None, - }, - ) - .await; - } + // A relay drain (SIGTERM / owner-drain) tore every local client down at + // once. That empties the room without anyone choosing to leave, so it must + // never end the huddle: release the lease so rejoiners re-acquire through + // Redis, and leave the channel alive. Ordinary last-leaver departures get a + // grace window before the room ends so a reconnecting client keeps its + // huddle. + let draining = owner_draining_after_leave.is_some_and(|token| token.is_cancelled()) + || relay_is_draining(&state); + let room_emptied = match idle { + Some(idle) if !draining => { + info!( + channel_id = %channel_id, + grace_secs = ROOM_EMPTY_GRACE.as_secs(), + "audio room empty — holding huddle open for rejoin" + ); + tokio::spawn(end_room_after_grace( + Arc::clone(&state), + tenant.clone(), + channel_id, + parent_id_for_event, + pubkey_hex.clone(), + Arc::clone(&room), + idle, + owner_lost_after_leave, + owner_generation, + )); + // The grace task now owns room cleanup and lease release. + false } - } else { - room_emptied = state + Some(idle) => { + info!(channel_id = %channel_id, "audio room emptied by relay drain — huddle stays alive"); + room.release_idle_hold(idle); + state + .audio_rooms + .cleanup_if_empty(tenant.community(), channel_id) + } + // Another peer was present under the same lock that removed this one + // (or this is an ingress mirror, which never observes idle). Cleanup is + // safe from any departure: a pending grace window pins the room in the + // manager, so a slow pre-last leaver cannot detach a room whose end is + // still in flight. + None => state .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); - } + .cleanup_if_empty(tenant.community(), channel_id), + }; // Owner path: release this room's lease when the room empties, so a new // owner can acquire and the renewer stops cleanly (silent, not owner-loss). @@ -965,9 +981,7 @@ async fn handle_active_audio_connection( // is a no-op for the stale generation and leaves the live renewer running. // Only the last leaver empties the room, so exactly one release fires. if room_emptied { - if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { - mesh.owners.release(channel_id, generation); - } + release_owner_lease(&state, channel_id, owner_generation); } info!( @@ -977,6 +991,134 @@ async fn handle_active_audio_connection( ); } +/// Whether this runtime is shutting down or draining its huddle ownership. +/// `shutting_down` flips on SIGTERM before the mesh watcher propagates it to +/// `owners.drain_all()`, and is the only signal in single-pod mode. +fn relay_is_draining(state: &AppState) -> bool { + state.shutting_down.load(Ordering::Relaxed) + || state.mesh().is_some_and(|mesh| mesh.owners.is_draining()) +} + +fn release_owner_lease(state: &AppState, channel_id: Uuid, owner_generation: Option) { + if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { + mesh.owners.release(channel_id, generation); + } +} + +/// Outcome of waiting out the empty-room grace window. +#[derive(Debug, PartialEq, Eq)] +enum IdleOutcome { + /// The grace window elapsed with no rejoin; the room is now `ended` and the + /// caller must archive + emit 48103. + Ended, + /// A peer was admitted during the window (whether or not it has since + /// left). That peer's own departure owns the next lifecycle decision. + Rejoined, + /// The lease was lost or the relay began draining before the window + /// elapsed. The room must not end; another owner may hold it now. + Aborted, +} + +/// Wait `grace`, then end the room if it is still idle. `owner_lost` aborts the +/// wait; `draining` is re-polled when the timer fires so a drain that began +/// mid-window (single-pod mode has no token) cannot end the huddle. +async fn await_room_idle( + room: &crate::audio::room::Room, + idle: crate::audio::room::IdleGeneration, + grace: Duration, + owner_lost: Option, + draining: impl Fn() -> bool, +) -> IdleOutcome { + let lost = async { + match owner_lost { + Some(token) => token.cancelled().await, + None => std::future::pending().await, + } + }; + tokio::select! { + // Owner loss wins a tie with the timer: another pod may own the room. + biased; + _ = lost => IdleOutcome::Aborted, + _ = tokio::time::sleep(grace) => { + if draining() { + IdleOutcome::Aborted + } else if room.end_if_idle(idle) { + IdleOutcome::Ended + } else { + IdleOutcome::Rejoined + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn end_room_after_grace( + state: Arc, + tenant: TenantContext, + channel_id: Uuid, + parent_id_for_event: Uuid, + last_leaver_pubkey: String, + room: Arc, + idle: crate::audio::room::IdleGeneration, + owner_lost: Option, + owner_generation: Option, +) { + let outcome = await_room_idle(&room, idle, ROOM_EMPTY_GRACE, owner_lost, || { + relay_is_draining(&state) + }) + .await; + let room_emptied = match outcome { + IdleOutcome::Rejoined => return, + IdleOutcome::Aborted => { + info!(channel_id = %channel_id, "audio room grace aborted — huddle stays alive"); + room.release_idle_hold(idle); + state + .audio_rooms + .cleanup_if_empty(tenant.community(), channel_id) + } + IdleOutcome::Ended => { + info!(channel_id = %channel_id, "audio room stayed empty — auto-ending huddle"); + match state + .db + .archive_channel(tenant.community(), channel_id) + .await + { + Err(e) => { + warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}"); + room.clear_ended(); + room.release_idle_hold(idle); + return; + } + Ok(()) => { + // The hold outlived `end_if_idle` so no joiner could land + // on a replacement room while the archive was in flight. + room.release_idle_hold(idle); + let emptied = state + .audio_rooms + .cleanup_if_empty(tenant.community(), channel_id); + emit_participant_event( + &state, + &tenant, + channel_id, + parent_id_for_event, + ParticipantLifecycle { + kind: Kind::Custom(48103), + participant_pubkey: &last_leaver_pubkey, + roster_revision: None, + admission_id: None, + }, + ) + .await; + emptied + } + } + } + }; + if room_emptied { + release_owner_lease(&state, channel_id, owner_generation); + } +} + /// React to a non-owner huddle teardown signal read off the owner's control /// stream: cancel the connection (which drives the client's WS to close so it /// rejoins) and forget the local generation floor for this session. @@ -1693,4 +1835,85 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + fn idle_room() -> (crate::audio::room::Room, crate::audio::room::IdleGeneration) { + let room = crate::audio::room::Room::new( + buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + Uuid::new_v4(), + ); + let (peer, ..) = room.add_peer("alice".into(), 3).expect("admit"); + let (_, idle) = room.remove_peer_and_check_idle(peer).expect("peer existed"); + (room, idle.expect("last leaver observes idle")) + } + + /// The grace window holds the room open; once it elapses with no rejoin the + /// room ends and refuses further admission. + #[tokio::test(start_paused = true)] + async fn empty_room_ends_only_after_grace_elapses() { + let (room, idle) = idle_room(); + let wait = await_room_idle(&room, idle, ROOM_EMPTY_GRACE, None, || false); + tokio::pin!(wait); + + tokio::time::advance(ROOM_EMPTY_GRACE - Duration::from_millis(1)).await; + assert!( + futures_util::poll!(&mut wait).is_pending(), + "room must stay open for the whole grace window" + ); + let (bob, ..) = room + .add_peer("bob".into(), 3) + .expect("rejoin is admitted during grace"); + room.remove_peer(bob); + + tokio::time::advance(Duration::from_millis(1)).await; + assert_eq!( + wait.await, + IdleOutcome::Rejoined, + "a rejoin during grace fences out the stale observation" + ); + assert!(room.add_peer("carol".into(), 3).is_ok(), "room never ended"); + } + + #[tokio::test(start_paused = true)] + async fn empty_room_with_no_rejoin_ends_after_grace() { + let (room, idle) = idle_room(); + let outcome = await_room_idle(&room, idle, ROOM_EMPTY_GRACE, None, || false).await; + assert_eq!(outcome, IdleOutcome::Ended); + assert!(matches!( + room.add_peer("bob".into(), 3), + Err(crate::audio::room::AdmissionError::Ended) + )); + } + + /// Owner-loss during the window aborts without ending: the room now belongs + /// to whichever pod re-acquires the lease. + #[tokio::test(start_paused = true)] + async fn owner_loss_during_grace_aborts_without_ending() { + let (room, idle) = idle_room(); + let lost = CancellationToken::new(); + let wait = await_room_idle(&room, idle, ROOM_EMPTY_GRACE, Some(lost.clone()), || false); + tokio::pin!(wait); + + tokio::time::advance(Duration::from_secs(1)).await; + lost.cancel(); + assert_eq!(wait.await, IdleOutcome::Aborted); + assert!(room.add_peer("bob".into(), 3).is_ok(), "room was not ended"); + } + + /// A drain that begins mid-window (single-pod mode has no drain token, only + /// `shutting_down`) must not end the huddle when the timer fires. + #[tokio::test(start_paused = true)] + async fn drain_during_grace_aborts_without_ending() { + let (room, idle) = idle_room(); + let draining = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = Arc::clone(&draining); + let wait = await_room_idle(&room, idle, ROOM_EMPTY_GRACE, None, move || { + flag.load(Ordering::Relaxed) + }); + tokio::pin!(wait); + + tokio::time::advance(Duration::from_secs(5)).await; + draining.store(true, Ordering::Relaxed); + assert_eq!(wait.await, IdleOutcome::Aborted); + assert!(room.add_peer("bob".into(), 3).is_ok(), "room was not ended"); + } } diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index 96cc66b4e07..818e2cd4c8c 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -2151,6 +2151,123 @@ mod tests { } } + // These bytes pin the production encoder/schema from main 1c8321cd. + // Postcard is positional: roundtripping the current types alone cannot + // detect a field insertion silently transposing a remote peer's identity. + #[test] + fn control_schema_matches_main_wire_fixtures() { + let peers = vec![ + RosterEntry { + pubkey: "a".into(), + peer_index: 7, + epoch: 3, + }, + RosterEntry { + pubkey: "bc".into(), + peer_index: 254, + epoch: 255, + }, + ]; + let mut fixtures: Vec<(HuddleControlMsg, &[u8])> = vec![ + ( + HuddleControlMsg::RegisterPeer { + community_id: Uuid::from_bytes([0x11; 16]), + pubkey: "a".into(), + protocol_version: 2, + }, + &[ + 0, 16, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 1, 97, 2, + ], + ), + ( + HuddleControlMsg::PeerRegistered { + pubkey: "a".into(), + peer_index: 7, + epoch: 3, + roster: RosterSnapshot { + revision: 300, + peers: peers.clone(), + }, + }, + &[1, 1, 97, 7, 3, 172, 2, 2, 1, 97, 7, 3, 2, 98, 99, 254, 255], + ), + ( + HuddleControlMsg::RosterSnapshot { + revision: 300, + peers: peers.clone(), + }, + &[2, 172, 2, 2, 1, 97, 7, 3, 2, 98, 99, 254, 255], + ), + ( + HuddleControlMsg::RosterSnapshot { + revision: 0, + peers: vec![], + }, + &[2, 0, 0], + ), + ( + HuddleControlMsg::RosterDelta { + revision: 301, + joined: Some(peers[0].clone()), + left: Some(peers[1].clone()), + }, + &[3, 173, 2, 1, 1, 97, 7, 3, 1, 2, 98, 99, 254, 255], + ), + ( + HuddleControlMsg::RosterDelta { + revision: 302, + joined: None, + left: None, + }, + &[3, 174, 2, 0, 0], + ), + (HuddleControlMsg::RosterResync, &[4]), + ( + HuddleControlMsg::UnregisterPeer { pubkey: "a".into() }, + &[6, 1, 97], + ), + ]; + for (reason, bytes) in [ + (RegisterRejection::RoomFull, &[5, 1, 97, 0][..]), + (RegisterRejection::RoomEnded, &[5, 1, 97, 1][..]), + ( + RegisterRejection::VersionMismatch { + pinned: 3, + requested: 2, + }, + &[5, 1, 97, 2, 3, 2][..], + ), + ( + RegisterRejection::Fenced(FenceRejection::StaleGeneration), + &[5, 1, 97, 3, 0][..], + ), + ( + RegisterRejection::Fenced(FenceRejection::NoActiveLease), + &[5, 1, 97, 3, 1][..], + ), + ( + RegisterRejection::Fenced(FenceRejection::OwnerMismatch), + &[5, 1, 97, 3, 2][..], + ), + ( + RegisterRejection::Fenced(FenceRejection::FutureGeneration), + &[5, 1, 97, 3, 3][..], + ), + ] { + fixtures.push(( + HuddleControlMsg::RegisterRejected { + pubkey: "a".into(), + reason, + }, + bytes, + )); + } + for (message, bytes) in fixtures { + assert_eq!(encode_control(&message).unwrap(), bytes, "{message:?}"); + assert_eq!(decode_control(bytes).unwrap(), message); + } + } + // ── In-memory MeshStream pair for handshake round-trip tests ───────────── // // A channel-backed `StreamSendHalf`/`StreamRecvHalf` pair drives diff --git a/crates/buzz-relay/src/audio/room.rs b/crates/buzz-relay/src/audio/room.rs index d2849f3e0bd..0c14a454130 100644 --- a/crates/buzz-relay/src/audio/room.rs +++ b/crates/buzz-relay/src/audio/room.rs @@ -117,6 +117,14 @@ pub type IndexedPeerAdmission = ( u64, ); +/// Snapshot of a room's admission history taken the moment it was observed +/// empty. Passed back to [`Room::end_if_idle`] after the grace window: if any +/// peer admitted in between, the snapshot is stale and the end is refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdleGeneration { + admissions: u64, +} + /// Reason a peer was refused entry to a room. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdmissionError { @@ -179,6 +187,18 @@ struct AdmissionGuard { /// this behavior. pinned_version: Option, roster_revision: u64, + /// Count of successful admissions. A room-empty observation captures this + /// value; [`Room::end_if_idle`] only ends the room if no admission has + /// happened since, so a rejoin during the empty-room grace window fences + /// out the stale auto-end without any explicit timer cancellation. + admissions: u64, + /// The last peer left and an [`IdleGeneration`] was handed out: a grace + /// window may be pending against *this* `Room`. While set, the manager + /// must not drop the room — a rejoiner has to land on the same `Room` so + /// its admission fences the pending [`Room::end_if_idle`]. Stays set + /// through `end_if_idle` while the archive is in flight; cleared by + /// admission or by [`Room::release_idle_hold`]. + idle_hold: bool, } impl AdmissionGuard { @@ -190,6 +210,8 @@ impl AdmissionGuard { ended: false, pinned_version: None, roster_revision: 0, + admissions: 0, + idle_hold: false, } } @@ -325,6 +347,8 @@ impl Room { protocol_version: requested_version, }, ); + g.admissions += 1; + g.idle_hold = false; g.roster_revision = g.roster_revision.wrapping_add(1); let revision = g.roster_revision; let delta = RosterDelta { @@ -386,6 +410,8 @@ impl Room { protocol_version: requested_version, }, ); + g.admissions += 1; + g.idle_hold = false; g.roster_revision = g.roster_revision.wrapping_add(1); let revision = g.roster_revision; let delta = RosterDelta { @@ -425,12 +451,16 @@ impl Room { Some(delta) } - /// Remove a peer AND atomically check if the room should end. - /// If the room is now empty, sets `ended = true` under the same lock - /// acquisition that removes the peer — no window for a concurrent - /// `add_peer` to sneak in between removal and the ended flag. - /// Returns `(roster_delta, should_auto_end)`. - pub fn remove_peer_and_check_ended(&self, peer_id: Uuid) -> Option<(RosterDelta, bool)> { + /// Remove a peer AND atomically observe whether it left the room empty. + /// Returns `(roster_delta, idle)` where `idle` is `Some` only for the + /// departure that emptied a not-yet-ended room. The [`IdleGeneration`] + /// captures the admission count under the same lock acquisition that + /// removed the peer, so the caller can wait out a grace window and then + /// call [`Self::end_if_idle`] without racing a concurrent `add_peer`. + pub fn remove_peer_and_check_idle( + &self, + peer_id: Uuid, + ) -> Option<(RosterDelta, Option)> { let mut g = self.guard.lock().ok()?; let (_, peer) = self.peers.remove(&peer_id)?; let peer_index = peer.peer_index; @@ -445,18 +475,60 @@ impl Room { epoch: peer.epoch, }), }; - // Only the first task to see empty + !ended wins the auto-end. - // This prevents duplicate archive/48103 when two peers disconnect - // simultaneously and both see is_empty() == true. - let should_end = if !g.ended && self.peers.is_empty() { - g.ended = true; - true - } else { - false - }; + let idle = (!g.ended && self.peers.is_empty()).then_some(IdleGeneration { + admissions: g.admissions, + }); + g.idle_hold |= idle.is_some(); let _ = self.roster_tx.send(delta.clone()); drop(g); - Some((delta, should_end)) + Some((delta, idle)) + } + + /// End the room if it is still idle: empty, not already ended, and no + /// admission has happened since `idle` was observed. Sets `ended = true` + /// under the admission lock so no `add_peer` can sneak in after the check. + /// Returns `true` exactly once per idle generation — two leavers who both + /// observed the same empty room cannot both end it, and a rejoin (even one + /// that has since left again) fences out the stale observation. + /// + /// The idle hold is retained: the caller still has to archive the channel, + /// and until that resolves the room must stay registered so a concurrent + /// joiner meets `ended` on this `Room` instead of a fresh one whose + /// pre-join DB check can race the archive. Call + /// [`Self::release_idle_hold`] once the archive has succeeded, or + /// [`Self::clear_ended`] + `release_idle_hold` if it failed. + pub fn end_if_idle(&self, idle: IdleGeneration) -> bool { + let Ok(mut g) = self.guard.lock() else { + return false; + }; + if g.ended || g.admissions != idle.admissions || !self.peers.is_empty() { + return false; + } + g.ended = true; + true + } + + /// Release the hold taken by the idle observation `idle`, so the manager + /// may evict the room — either because the grace window was abandoned + /// (drain, owner loss) or because the end it guarded has fully resolved. + /// Fenced like [`Self::end_if_idle`]: a later admission owns its own hold, + /// which a stale release must not lift. + pub fn release_idle_hold(&self, idle: IdleGeneration) { + if let Ok(mut g) = self.guard.lock() { + if g.admissions == idle.admissions { + g.idle_hold = false; + } + } + } + + /// True when the room may be dropped from the manager: no peers, and no + /// grace window or in-flight end pending that a rejoiner would need to + /// land on. Both are read under the admission lock so a concurrent + /// `add_peer` cannot slip a peer in between the two checks. + fn is_evictable(&self) -> bool { + self.guard + .lock() + .is_ok_and(|g| !g.idle_hold && self.peers.is_empty()) } /// Fan-out a binary frame to all peers except the sender. Protocol v3 @@ -621,10 +693,16 @@ impl AudioRoomManager { Some(room) } - /// Remove the room if it has no peers. Returns `true` if the room was removed. + /// Remove the room if it has no peers and no pending grace window. + /// Returns `true` if the room was removed. + /// + /// A room whose last peer just left stays registered until its grace + /// window resolves ([`Room::end_if_idle`] / [`Room::release_idle_hold`]): + /// evicting it early would hand a rejoiner a fresh `Room` that the pending + /// end cannot see, and the stale end would then archive the live huddle. pub fn cleanup_if_empty(&self, community_id: CommunityId, channel_id: Uuid) -> bool { self.rooms - .remove_if(&(community_id, channel_id), |_, room| room.is_empty()) + .remove_if(&(community_id, channel_id), |_, room| room.is_evictable()) .is_some() } } @@ -787,11 +865,13 @@ mod tests { let (peer_id, _, _, _, _, _) = room1 .add_peer("alice".to_string(), 2) .expect("first peer admits"); - // Last peer leaves and ends the room atomically. - let (_, ended) = room1 - .remove_peer_and_check_ended(peer_id) + // Last peer leaves; the idle observation ends the room after grace. + let (_, idle) = room1 + .remove_peer_and_check_idle(peer_id) .expect("peer existed"); - assert!(ended, "single-peer room should end on its last departure"); + let idle = idle.expect("single-peer room should be idle on its last departure"); + assert!(room1.end_if_idle(idle), "idle room ends"); + room1.release_idle_hold(idle); assert!(manager.cleanup_if_empty(community_id, channel_id)); // Next joiner with a different version on the same channel id gets a @@ -993,4 +1073,195 @@ mod tests { // And the room state must be unchanged. assert_eq!(room.peers.len(), MAX_PEERS_PER_ROOM); } + + /// Only the departure that empties the room observes idleness; the + /// observation then ends the room exactly once. + #[test] + fn idle_observed_only_by_last_leaver_and_ends_once() { + let room = fresh_room(); + let (alice, ..) = room.add_peer("alice".into(), 2).unwrap(); + let (bob, ..) = room.add_peer("bob".into(), 2).unwrap(); + + let (_, idle) = room.remove_peer_and_check_idle(alice).unwrap(); + assert!(idle.is_none(), "room still has bob"); + let (_, idle) = room.remove_peer_and_check_idle(bob).unwrap(); + let idle = idle.expect("bob emptied the room"); + + assert!(room.end_if_idle(idle)); + assert!( + !room.end_if_idle(idle), + "second end on the same generation is a no-op" + ); + assert!(matches!( + room.add_peer("carol".into(), 2), + Err(AdmissionError::Ended) + )); + } + + /// A rejoin during the grace window fences out the stale idle observation, + /// even if the rejoiner has already left again — its own departure owns the + /// next lifecycle decision. + #[test] + fn rejoin_during_grace_fences_stale_idle() { + let room = fresh_room(); + let (alice, ..) = room.add_peer("alice".into(), 2).unwrap(); + let (_, idle) = room.remove_peer_and_check_idle(alice).unwrap(); + let stale = idle.unwrap(); + + let (alice_again, ..) = room + .add_peer("alice".into(), 2) + .expect("room is not ended during grace"); + assert!(!room.end_if_idle(stale), "occupied room never ends"); + + let (_, idle) = room.remove_peer_and_check_idle(alice_again).unwrap(); + let fresh = idle.expect("alice emptied the room again"); + assert!( + !room.end_if_idle(stale), + "stale generation cannot end a room a later admission touched" + ); + assert!(room.end_if_idle(fresh), "the fresh observation ends it"); + } + + /// Mari's concurrent-leaver ordering: Alice removes first (sees Bob, gets + /// no idle), Bob removes last (gets the idle generation), then Alice's + /// delayed `cleanup_if_empty` runs. The registry must keep the room pinned + /// so a rejoiner lands on the same `Room` and fences Bob's pending end; + /// otherwise the stale end would archive underneath a live replacement. + #[test] + fn pending_grace_pins_room_in_manager_across_concurrent_leavers() { + let manager = AudioRoomManager::new(); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let channel_id = Uuid::new_v4(); + let room = manager.get_or_create(community_id, channel_id); + let (alice, ..) = room.add_peer("alice".into(), 3).unwrap(); + let (bob, ..) = room.add_peer("bob".into(), 3).unwrap(); + + let (_, alice_idle) = room.remove_peer_and_check_idle(alice).unwrap(); + assert!(alice_idle.is_none(), "Bob is still present"); + let (_, bob_idle) = room.remove_peer_and_check_idle(bob).unwrap(); + let bob_idle = bob_idle.expect("Bob emptied the room"); + + // Alice's cleanup arrives late, after Bob's idle observation. + assert!( + !manager.cleanup_if_empty(community_id, channel_id), + "an empty room with a pending grace window must not be evicted" + ); + let rejoin_room = manager.get_or_create(community_id, channel_id); + assert!( + Arc::ptr_eq(&room, &rejoin_room), + "rejoin must land on the room the grace task holds" + ); + let (carol, ..) = rejoin_room.add_peer("carol".into(), 3).unwrap(); + assert!( + !room.end_if_idle(bob_idle), + "Carol's admission fences Bob's stale end" + ); + + // Carol leaves; her observation owns the lifecycle and ends the room. + let (_, carol_idle) = room.remove_peer_and_check_idle(carol).unwrap(); + let carol_idle = carol_idle.unwrap(); + assert!(room.end_if_idle(carol_idle)); + room.release_idle_hold(carol_idle); + assert!(manager.cleanup_if_empty(community_id, channel_id)); + assert!(manager.get(community_id, channel_id).is_none()); + } + + /// Mari's archive-in-flight ordering: the grace window expires and + /// `end_if_idle` succeeds, but the archive write has not resolved yet. A + /// delayed `cleanup_if_empty` must not detach the ended room, and a joiner + /// arriving in that gap must meet `ended` on the same `Room` rather than + /// admit into a fresh one whose pre-join DB check raced the archive. + #[test] + fn ended_room_stays_pinned_until_archive_resolves() { + let manager = AudioRoomManager::new(); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let channel_id = Uuid::new_v4(); + let room = manager.get_or_create(community_id, channel_id); + let (alice, ..) = room.add_peer("alice".into(), 3).unwrap(); + let idle = room.remove_peer_and_check_idle(alice).unwrap().1.unwrap(); + assert!(room.end_if_idle(idle)); + + // Archive is in flight: cleanup cannot detach, and a joiner lands on + // the ended room and is refused. + assert!( + !manager.cleanup_if_empty(community_id, channel_id), + "an ended room with its archive in flight must stay registered" + ); + let joiner_room = manager.get_or_create(community_id, channel_id); + assert!(Arc::ptr_eq(&room, &joiner_room)); + assert!(matches!( + joiner_room.add_peer("bob".into(), 3), + Err(AdmissionError::Ended) + )); + + // Archive succeeded: release, then the manager may evict. + room.release_idle_hold(idle); + assert!(manager.cleanup_if_empty(community_id, channel_id)); + assert!(manager.get(community_id, channel_id).is_none()); + } + + /// Archive failure rolls the end back: the room reopens, the hold is + /// released, and the next joiner gets a live room again. + #[test] + fn failed_archive_reopens_room_and_releases_hold() { + let manager = AudioRoomManager::new(); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let channel_id = Uuid::new_v4(); + let room = manager.get_or_create(community_id, channel_id); + let (alice, ..) = room.add_peer("alice".into(), 3).unwrap(); + let idle = room.remove_peer_and_check_idle(alice).unwrap().1.unwrap(); + assert!(room.end_if_idle(idle)); + + room.clear_ended(); + room.release_idle_hold(idle); + assert!( + manager.cleanup_if_empty(community_id, channel_id), + "a reopened, empty, unheld room is evictable" + ); + let next = manager.get_or_create(community_id, channel_id); + assert!(next.add_peer("bob".into(), 3).is_ok(), "fresh room admits"); + } + + /// Drain variant: the last leaver's idle observation is abandoned (the + /// huddle must outlive the pod), so it releases its hold and the room is + /// evicted. A pre-last teardown's cleanup racing ahead of that release + /// still cannot detach the room, and a stale release after a rejoin cannot + /// lift the rejoiner's hold. + #[test] + fn released_hold_allows_eviction_but_stale_release_does_not() { + let manager = AudioRoomManager::new(); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let channel_id = Uuid::new_v4(); + let room = manager.get_or_create(community_id, channel_id); + let (alice, ..) = room.add_peer("alice".into(), 3).unwrap(); + let (bob, ..) = room.add_peer("bob".into(), 3).unwrap(); + + assert!(room.remove_peer_and_check_idle(alice).unwrap().1.is_none()); + let drained = room.remove_peer_and_check_idle(bob).unwrap().1.unwrap(); + assert!( + !manager.cleanup_if_empty(community_id, channel_id), + "pre-last cleanup must wait for the drain-owned release" + ); + + // Rejoin before the release lands: the rejoiner owns a fresh hold. + let (carol, ..) = room.add_peer("carol".into(), 3).unwrap(); + let fresh = room.remove_peer_and_check_idle(carol).unwrap().1.unwrap(); + room.release_idle_hold(drained); + assert!( + !manager.cleanup_if_empty(community_id, channel_id), + "a stale release must not lift a later observation's hold" + ); + + room.release_idle_hold(fresh); + assert!( + manager.cleanup_if_empty(community_id, channel_id), + "released hold on an empty room permits eviction" + ); + let next = manager.get_or_create(community_id, channel_id); + assert!(!Arc::ptr_eq(&room, &next), "evicted room is replaced"); + assert!( + next.add_peer("dave".into(), 2).is_ok(), + "fresh room, no pin" + ); + } } diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 383a7e8210a..6335100dd41 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -35,7 +35,7 @@ fn list_audio_output_devices_blocking() -> Result, String } /// Set the preferred audio output device by name. Empty string = system default. -/// Takes effect on the next huddle start/join (does not change a live stream). +/// An active huddle moves subsequent playout to the selected route immediately. #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state @@ -43,7 +43,12 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu .output_device .lock() .map_err(|e| e.to_string())?; - *guard = if name.is_empty() { None } else { Some(name) }; + let selected = if name.is_empty() { None } else { Some(name) }; + *guard = selected.clone(); + state + .huddle_audio + .output_device_changes + .send_replace(selected); Ok(()) } diff --git a/desktop/src-tauri/src/huddle/human_floor.rs b/desktop/src-tauri/src/huddle/human_floor.rs index 1643880c42c..45415c7a1c7 100644 --- a/desktop/src-tauri/src/huddle/human_floor.rs +++ b/desktop/src-tauri/src/huddle/human_floor.rs @@ -7,6 +7,7 @@ use super::tts_playback::{HumanFloorAuthorization, PlaybackCoordinator}; #[derive(Clone)] pub(crate) struct HumanFloor { playback: Arc, + remote_scope: uuid::Uuid, } impl std::fmt::Debug for HumanFloor { @@ -25,6 +26,15 @@ impl HumanFloor { pub(crate) fn new() -> Self { Self { playback: Arc::new(PlaybackCoordinator::unbound()), + remote_scope: uuid::Uuid::new_v4(), + } + } + + /// Share local/TTS coordination, but isolate one audio connection's peers. + pub(crate) fn for_audio_connection(&self) -> Self { + Self { + playback: Arc::clone(&self.playback), + remote_scope: uuid::Uuid::new_v4(), } } @@ -60,14 +70,16 @@ impl HumanFloor { } pub(crate) fn enter_remote(&self, peer: u8) { - self.playback.enter_remote_human_floor(peer); + self.playback + .enter_remote_human_floor(self.remote_scope, peer); } pub(crate) fn leave_remote(&self, peer: u8) { - self.playback.leave_remote_human_floor(peer); + self.playback + .leave_remote_human_floor(self.remote_scope, peer); } pub(crate) fn clear_remote(&self) { - self.playback.clear_remote_human_floor(); + self.playback.clear_remote_human_floor(self.remote_scope); } } diff --git a/desktop/src-tauri/src/huddle/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs index f928ddbce0f..2f7787df65c 100644 --- a/desktop/src-tauri/src/huddle/latency_bench.rs +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -143,8 +143,8 @@ fn baseline_stt_fake_llm_tts_first_audio() { Arc::clone(&tts_cancel), super::human_floor::HumanFloor::new(), "eve", - None, // default output device - None, // no Tauri app handle + tokio::sync::watch::channel(None).1, // default output device + None, // no Tauri app handle ) .expect("tts pipeline"); eprintln!( @@ -158,7 +158,7 @@ fn baseline_stt_fake_llm_tts_first_audio() { None, None, super::human_floor::HumanFloor::new(), - None, + tokio::sync::watch::channel(None).1, ) .expect("stt pipeline"); // Recognizer loads inside the worker thread; give it time, then verify diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index e219b2f75fa..21f1b9bca7e 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -499,8 +499,8 @@ fn teardown_huddle(state: &AppState) -> Result<(), String> { let cancel = hs.audio_ws_cancel.take(); // Cancel the relay token BEFORE dropping the sender. If we drop // pcm_tx first, the send task sees None from recv() and can exit - // the pipeline before is_cancelled() is true — causing a spurious - // huddle-audio-disconnected event on intentional teardown. + // the pipeline before is_cancelled() is true — and would start a + // spurious audio reconnect on intentional teardown. if let Some(ref c) = cancel { c.cancel(); } @@ -601,6 +601,7 @@ pub async fn leave_huddle(app: tauri::AppHandle, state: State<'_, AppState>) -> return Ok(()); // Nothing to leave. } hs.phase = HuddlePhase::Leaving; + hs.huddle_cancel.cancel(); ( hs.parent_channel_id.clone().unwrap_or_default(), hs.ephemeral_channel_id.clone().unwrap_or_default(), @@ -674,6 +675,7 @@ pub async fn end_huddle( return Err("only the huddle creator can end it — use leave_huddle instead".into()); } hs.phase = HuddlePhase::Leaving; + hs.huddle_cancel.cancel(); ( hs.parent_channel_id.clone().unwrap_or_default(), hs.ephemeral_channel_id.clone().unwrap_or_default(), diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 47d4aeb43d1..4254bbc729c 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -250,7 +250,7 @@ pub(crate) async fn post_connect_setup( } return Ok(PostConnectOutcome::Stale); } - let (cancel, pcm_tx) = audio_result?; + let (cancel, pcm_tx) = audio_result.map_err(|error| error.to_string())?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } @@ -349,12 +349,7 @@ pub(crate) async fn maybe_start_stt_pipeline( ptt, manual_mic_unmuted, hs.human_floor.clone(), - state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(), + state.huddle_audio.output_device_changes.subscribe(), old, ) }; @@ -447,12 +442,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, ws_tx_for_pongs: Arc>>, - sink_handle: rodio::MixerDeviceSink, + mut sink_handle: rodio::MixerDeviceSink, cancel: CancellationToken, app_handle: Option, initial_peers: Vec<(u8, String, u8)>, @@ -283,6 +283,7 @@ pub(crate) async fn run_playout_recv_loop( remote_stt_pipeline: Arc>>>, agent_pubkeys: Arc>>, human_floor: HumanFloor, + mut output_device_changes: tokio::sync::watch::Receiver>, ) { use rodio::buffer::SamplesBuffer; use std::num::NonZero; @@ -328,6 +329,25 @@ pub(crate) async fn run_playout_recv_loop( tokio::select! { biased; _ = cancel.cancelled() => break, + changed = output_device_changes.changed() => { + if changed.is_err() { + break; + } + let selected = output_device_changes.borrow_and_update().clone(); + match super::audio_output::open_output_sink_by_name(selected.as_deref()) { + Ok(next_sink) => { + sink_handle = next_sink; + let mixer = sink_handle.mixer().clone(); + for slot in peers.values_mut() { + slot.player = rodio::Player::connect_new(&mixer); + slot.recovering_playout = false; + } + } + Err(error) => { + eprintln!("buzz-desktop: live output device switch failed: {error}"); + } + } + } _ = playout_tick.tick() => { // Drain one 10 ms frame from each *active* peer's NetEq into // its Player. NetEq always emits a frame (Expand/silence when @@ -655,7 +675,7 @@ pub(crate) async fn run_playout_recv_loop( } } - human_floor.clear_remote(); + // The supervisor clears this connection's floor after joining all children. if let Some(ref app) = app_handle { use tauri::Emitter; let _ = app.emit( diff --git a/desktop/src-tauri/src/huddle/reconnect.rs b/desktop/src-tauri/src/huddle/reconnect.rs index 6996776f1c9..6e089e3a44e 100644 --- a/desktop/src-tauri/src/huddle/reconnect.rs +++ b/desktop/src-tauri/src/huddle/reconnect.rs @@ -1,52 +1,229 @@ -//! Audio-only huddle reconnection after an unexpected relay disconnect. +//! Rust-owned audio reconnect after an unexpected relay disconnect. +//! +//! Only the audio relay WebSocket is rebuilt. Huddle membership, mic capture, +//! STT/TTS, and agent voice stay live because `phase` never leaves `Active`; +//! the renderer sees progress through `HuddleState::audio_link` instead. +//! +//! The loop is fenced by huddle identity (`is_current_huddle`) after every +//! await: an intentional leave, or a replacement huddle started during the +//! backoff, always wins and any socket opened for the old huddle is cancelled +//! rather than installed. -use std::sync::atomic::Ordering; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; -use tauri::State; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; -use super::{relay_api, HuddlePhase}; +use super::relay_api::{self, AudioRelayConnectError}; +use super::state::{AudioLink, HuddleState}; -/// Re-establish only the audio relay WebSocket after an unexpected owner/pod -/// disconnect. Huddle membership, mic capture, STT/TTS, and frontend state stay -/// live, so a successful reconnect is a short audio blip rather than a leave. -/// -/// The session generation and channel id are re-checked after the network dial: -/// an intentional leave/end racing this command wins and the newly-opened audio -/// pipeline is cancelled instead of resurrecting a terminal huddle. -#[tauri::command] -pub async fn reconnect_huddle_audio(state: State<'_, AppState>) -> Result<(), String> { - let (ephemeral_channel_id, parent_channel_id, session_generation) = { - let hs = state.huddle()?; - if matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) { - return Err("huddle is no longer active".into()); +/// Recovery, including pending dials, is bounded by this window. Long +/// enough to ride out a relay deploy (pod drain + Service endpoint +/// convergence) and a laptop Wi-Fi roam without dropping the huddle. +pub(crate) const RECONNECT_WINDOW: Duration = Duration::from_secs(60); +/// First backoff step after a failed dial; doubles per failure up to the ceiling. +const BACKOFF_BASE: Duration = Duration::from_millis(250); +const DIAL_TIMEOUT: Duration = Duration::from_secs(10); +const BACKOFF_MAX: Duration = Duration::from_secs(5); +/// A `huddle_relay_draining` refusal means a replacement pod is coming up, so +/// redial at the floor instead of growing the backoff. +const DRAINING_REDIAL_DELAY: Duration = BACKOFF_BASE; + +/// Identity of the huddle whose audio socket is being rebuilt. +#[derive(Debug, Clone)] +pub(crate) struct ReconnectTarget { + pub ephemeral_channel_id: String, + pub parent_channel_id: Option, + huddle_generation: u64, + lifetime: CancellationToken, +} + +impl ReconnectTarget { + pub(crate) fn capture(hs: &HuddleState, channel_id: &str, parent: Option<&str>) -> Self { + Self { + ephemeral_channel_id: channel_id.to_owned(), + parent_channel_id: parent.map(str::to_owned), + huddle_generation: hs.huddle_generation, + lifetime: hs.huddle_cancel.clone(), } - ( - hs.ephemeral_channel_id - .clone() - .ok_or("active huddle has no channel id")?, - hs.parent_channel_id.clone(), - hs.session_generation.load(Ordering::Acquire), - ) + } +} + +pub(crate) type AudioConnection = (CancellationToken, tokio::sync::mpsc::Sender>); + +/// Entry point for the audio pipeline task when its socket exits unexpectedly. +/// Awaited in place by that task, so the loop ends with it; nothing is spawned. +/// +/// Boxed because the call graph is recursive (pipeline task → reconnect → +/// `connect_audio_relay` → pipeline task) and the compiler needs a type-erased +/// edge to prove the future is `Send`. +pub(crate) fn after_unexpected_disconnect( + app: tauri::AppHandle, + target: ReconnectTarget, +) -> Pin + Send>> { + Box::pin(async move { + use tauri::Manager; + let state = app.state::(); + let state: &AppState = &state; + run(state, target, |target: ReconnectTarget| async move { + relay_api::connect_audio_relay( + &target.ephemeral_channel_id, + target.parent_channel_id.as_deref(), + state, + ) + .await + }) + .await; + }) +} + +/// Claim recovery only for the pipeline that actually disconnected, never +/// whichever huddle happens to be current when its callback runs. +fn claim_reconnect(hs: &mut HuddleState, target: &ReconnectTarget) -> bool { + if hs.audio_link != AudioLink::Live + || target.lifetime.is_cancelled() + || !hs.is_current_huddle(&target.ephemeral_channel_id, target.huddle_generation) + { + return false; + } + hs.audio_link = AudioLink::Reconnecting { + attempt: 0, + draining: false, }; + hs.audio_relay_pcm_tx = None; + true +} - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(&ephemeral_channel_id, parent_channel_id.as_deref(), &state) - .await?; - - let mut hs = state.huddle()?; - let still_current = !matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) - && hs.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id.as_str()) - && hs.session_generation.load(Ordering::Acquire) == session_generation; - if !still_current { - cancel.cancel(); - return Err("huddle ended while audio was reconnecting".into()); +/// Drive redials with `dial` until one succeeds, the huddle ends or is +/// replaced, or the retry window is exhausted (`AudioLink::Lost`). +pub(crate) async fn run(state: &AppState, target: ReconnectTarget, mut dial: F) +where + F: FnMut(ReconnectTarget) -> Fut, + Fut: Future>, +{ + if !state + .huddle() + .map(|mut hs| claim_reconnect(&mut hs, &target)) + .unwrap_or(false) + { + return; } + state.emit_huddle_state_changed(); + let deadline = Instant::now() + RECONNECT_WINDOW; + let mut failures: u32 = 0; + + loop { + // Do not start another dial at the budget boundary. The whole future + // includes TCP/TLS/WS upgrade as well as the authenticated handshake. + let result = if Instant::now() >= deadline { + Err("audio recovery deadline elapsed".into()) + } else { + tokio::select! { + biased; + result = tokio::time::timeout_at( + deadline.min(Instant::now() + DIAL_TIMEOUT), dial(target.clone()) + ) => result.unwrap_or_else(|_| Err("audio relay dial timed out".into())), + _ = target.lifetime.cancelled() => return, + } + }; - if let Some(old_cancel) = hs.audio_ws_cancel.replace(cancel) { - old_cancel.cancel(); + // Lock scope is a block, not `drop()`: the guard must provably end + // before the sleep for the future to stay `Send`. + let next_delay = { + let Ok(mut hs) = state.huddle() else { return }; + if target.lifetime.is_cancelled() + || !hs.is_current_huddle(&target.ephemeral_channel_id, target.huddle_generation) + { + // Leave or replacement won while we were dialing; never resurrect. + if let Ok((cancel, _)) = result { + cancel.cancel(); + } + return; + } + // The replacement pipeline is spawned before its handles reach us + // and cancels its own token on an unexpected exit. Its disconnect + // callback cannot claim a reconnect while this loop owns the + // link, so a token that is already cancelled here is a dial that + // failed after auth — retry it, never install it as Live. + let result = result.and_then(|conn| { + if conn.0.is_cancelled() { + Err("replacement pipeline exited before it was installed".into()) + } else { + Ok(conn) + } + }); + match result { + Ok((cancel, pcm_tx)) => { + if let Some(old_cancel) = hs.audio_ws_cancel.replace(cancel) { + old_cancel.cancel(); + } + hs.audio_relay_pcm_tx = Some(pcm_tx); + hs.audio_link = AudioLink::Live; + None + } + Err(error) => { + failures += 1; + let draining = error.code() == Some("huddle_relay_draining"); + eprintln!("buzz-desktop: huddle audio redial {failures} failed: {error}"); + if Instant::now() >= deadline { + hs.audio_link = AudioLink::Lost; + None + } else { + hs.audio_link = AudioLink::Reconnecting { + attempt: failures, + draining, + }; + Some(if draining { + DRAINING_REDIAL_DELAY + } else { + backoff_delay(failures, jitter_unit()) + }) + } + } + } + }; + state.emit_huddle_state_changed(); + let Some(delay) = next_delay else { return }; + tokio::select! { + biased; + _ = target.lifetime.cancelled() => return, + _ = tokio::time::sleep_until(deadline.min(Instant::now() + delay)) => {}, + } + if !is_current(state, &target) { + return; + } } - hs.audio_relay_pcm_tx = Some(pcm_tx); - Ok(()) } + +fn is_current(state: &AppState, target: &ReconnectTarget) -> bool { + state + .huddle() + .map(|hs| hs.is_current_huddle(&target.ephemeral_channel_id, target.huddle_generation)) + .unwrap_or(false) +} + +/// Exponential delay for the `failures`-th consecutive failure, capped at +/// `BACKOFF_MAX`, then scaled into `[0.5, 1.0]` by `jitter` so clients that +/// lost the same pod do not redial in lockstep. +fn backoff_delay(failures: u32, jitter: f64) -> Duration { + let exponent = failures.saturating_sub(1).min(16); + let full = BACKOFF_BASE + .saturating_mul(1_u32 << exponent) + .min(BACKOFF_MAX); + full.mul_f64(0.5 + 0.5 * jitter.clamp(0.0, 1.0)) +} + +fn jitter_unit() -> f64 { + let mut bytes = [0u8; 4]; + // Entropy failure degrades to no jitter; the window is still honoured. + let _ = getrandom::getrandom(&mut bytes); + f64::from(u32::from_le_bytes(bytes)) / f64::from(u32::MAX) +} + +#[cfg(test)] +#[path = "reconnect_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/reconnect_tests.rs b/desktop/src-tauri/src/huddle/reconnect_tests.rs new file mode 100644 index 00000000000..984f017fe44 --- /dev/null +++ b/desktop/src-tauri/src/huddle/reconnect_tests.rs @@ -0,0 +1,523 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +use super::{backoff_delay, run, AudioConnection, ReconnectTarget, RECONNECT_WINDOW}; +use crate::app_state::{build_app_state, AppState}; +use crate::huddle::relay_api::AudioRelayConnectError; +use crate::huddle::state::{AudioLink, HuddlePhase}; + +const HUDDLE: &str = "huddle-a"; + +fn live_huddle_state() -> Arc { + let state = build_app_state(); + { + let mut hs = state.huddle().expect("huddle lock"); + hs.phase = HuddlePhase::Active; + hs.ephemeral_channel_id = Some(HUDDLE.into()); + hs.parent_channel_id = Some("parent".into()); + hs.begin_huddle_lifetime(); + hs.audio_ws_cancel = Some(CancellationToken::new()); + } + Arc::new(state) +} + +fn target(state: &AppState) -> ReconnectTarget { + ReconnectTarget::capture(&state.huddle().unwrap(), HUDDLE, Some("parent")) +} + +fn connection() -> AudioConnection { + (CancellationToken::new(), tokio::sync::mpsc::channel(1).0) +} + +fn refused(code: &str) -> AudioRelayConnectError { + AudioRelayConnectError::from_relay_payload(&serde_json::json!({ + "code": code, + "message": "refused", + })) +} + +fn audio_link(state: &AppState) -> AudioLink { + state.huddle().expect("huddle lock").audio_link.clone() +} + +/// Dial that fails until `succeed_after` of virtual time has passed. +fn flaky_dial( + dials: Arc, + started: Instant, + succeed_after: Option, +) -> impl FnMut(ReconnectTarget) -> std::future::Ready> +{ + move |_| { + dials.fetch_add(1, Ordering::SeqCst); + let ok = succeed_after.is_some_and(|after| started.elapsed() >= after); + std::future::ready(if ok { + Ok(connection()) + } else { + Err("connection refused".into()) + }) + } +} + +#[tokio::test(start_paused = true)] +async fn recovers_after_a_thirty_five_second_outage_without_leaving() { + let state = live_huddle_state(); + let dials = Arc::new(AtomicU32::new(0)); + let started = Instant::now(); + + run( + &state, + target(&state), + flaky_dial(Arc::clone(&dials), started, Some(Duration::from_secs(35))), + ) + .await; + + assert!(started.elapsed() >= Duration::from_secs(35)); + assert!(dials.load(Ordering::SeqCst) > 1, "must have retried"); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.audio_link, AudioLink::Live); + assert_eq!(hs.phase, HuddlePhase::Active, "phase never leaves Active"); + assert!(hs.audio_relay_pcm_tx.is_some(), "new sender installed"); + assert!(hs + .audio_ws_cancel + .as_ref() + .is_some_and(|c| !c.is_cancelled())); +} + +#[tokio::test(start_paused = true)] +async fn draining_refusal_redials_promptly_instead_of_backing_off() { + let state = live_huddle_state(); + let dial_times = Arc::new(std::sync::Mutex::new(Vec::::new())); + let times = Arc::clone(&dial_times); + + run(&state, target(&state), move |_| { + let mut t = times.lock().unwrap(); + t.push(Instant::now()); + let n = t.len(); + std::future::ready(match n { + // Two ordinary refusals grow the backoff; the drain hint resets it. + 1 | 2 => Err("connection refused".into()), + 3 => Err(refused("huddle_relay_draining")), + _ => Ok(connection()), + }) + }) + .await; + + let t = dial_times.lock().unwrap(); + assert_eq!(t.len(), 4); + let ordinary_gap = t[2] - t[1]; + let draining_gap = t[3] - t[2]; + assert!( + draining_gap <= Duration::from_millis(250), + "{draining_gap:?}" + ); + assert!( + draining_gap < ordinary_gap, + "{draining_gap:?} vs {ordinary_gap:?}" + ); + assert_eq!(audio_link(&state), AudioLink::Live); +} + +#[tokio::test(start_paused = true)] +async fn draining_is_visible_in_state_while_waiting() { + let state = live_huddle_state(); + let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); + let observer = Arc::clone(&seen); + let observed_state = Arc::clone(&state); + + run(&state, target(&state), move |_| { + observer.lock().unwrap().push(audio_link(&observed_state)); + let n = observer.lock().unwrap().len(); + std::future::ready(match n { + 1 => Err(refused("huddle_relay_draining")), + _ => Ok(connection()), + }) + }) + .await; + + let seen = seen.lock().unwrap(); + assert_eq!( + seen.as_slice(), + [ + AudioLink::Reconnecting { + attempt: 0, + draining: false + }, + AudioLink::Reconnecting { + attempt: 1, + draining: true + }, + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn leave_during_backoff_stops_the_loop_and_never_installs_a_socket() { + let state = live_huddle_state(); + let dials = Arc::new(AtomicU32::new(0)); + let loop_state = Arc::clone(&state); + let loop_dials = Arc::clone(&dials); + + let loop_task = tokio::spawn(async move { + run(&loop_state, target(&loop_state), move |_| { + loop_dials.fetch_add(1, Ordering::SeqCst); + std::future::ready(Err::("connection refused".into())) + }) + .await; + }); + tokio::task::yield_now().await; + assert!(matches!(audio_link(&state), AudioLink::Reconnecting { .. })); + + // Intentional leave while the loop sleeps between dials. + state + .huddle() + .expect("huddle lock") + .reset_preserving_generation(); + + tokio::time::timeout(Duration::from_millis(1), loop_task) + .await + .expect("loop must exit on leave, not run out the window") + .expect("loop task"); + let dials_at_exit = dials.load(Ordering::SeqCst); + assert!(dials_at_exit <= 2, "{dials_at_exit} dials after leave"); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.phase, HuddlePhase::Idle); + assert_eq!(hs.audio_link, AudioLink::Live, "idle state is not Lost"); + assert!(hs.audio_ws_cancel.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn replacement_huddle_during_backoff_is_left_untouched() { + let state = live_huddle_state(); + let loop_state = Arc::clone(&state); + let returned = Arc::new(std::sync::Mutex::new(Vec::::new())); + let returned_for_dial = Arc::clone(&returned); + + let loop_task = tokio::spawn(async move { + run(&loop_state, target(&loop_state), move |_| { + let mut r = returned_for_dial.lock().unwrap(); + std::future::ready(if r.is_empty() { + // First dial fails so the loop sleeps; the huddle is swapped + // underneath it, and the next dial "succeeds" for the old one. + r.push(CancellationToken::new()); + Err("connection refused".into()) + } else { + let (cancel, tx) = connection(); + r.push(cancel.clone()); + Ok((cancel, tx)) + }) + }) + .await; + }); + tokio::task::yield_now().await; + + let replacement_cancel = CancellationToken::new(); + { + let mut hs = state.huddle().expect("huddle lock"); + hs.reset_preserving_generation(); + hs.phase = HuddlePhase::Active; + hs.ephemeral_channel_id = Some("huddle-b".into()); + hs.begin_huddle_lifetime(); + hs.audio_ws_cancel = Some(replacement_cancel.clone()); + } + + tokio::time::timeout(Duration::from_millis(1), loop_task) + .await + .expect("loop must exit when the huddle is replaced") + .expect("loop task"); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.ephemeral_channel_id.as_deref(), Some("huddle-b")); + assert!( + !replacement_cancel.is_cancelled(), + "new huddle's socket untouched" + ); + assert_eq!(hs.audio_link, AudioLink::Live); + let returned = returned.lock().unwrap(); + // Any socket opened for the stale huddle is cancelled, not installed. + for stale in returned.iter().skip(1) { + assert!(stale.is_cancelled()); + } +} + +#[tokio::test(start_paused = true)] +async fn leave_while_a_dial_is_in_flight_cancels_the_fresh_socket() { + let state = live_huddle_state(); + let dial_state = Arc::clone(&state); + let fresh = CancellationToken::new(); + let fresh_for_dial = fresh.clone(); + + run(&state, target(&state), move |_| { + // The user leaves while the dial is on the wire; the dial still wins + // a socket for the huddle that no longer exists. + dial_state + .huddle() + .expect("huddle lock") + .reset_preserving_generation(); + std::future::ready(Ok(( + fresh_for_dial.clone(), + tokio::sync::mpsc::channel(1).0, + ))) + }) + .await; + + assert!(fresh.is_cancelled(), "stale socket must be cancelled"); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.phase, HuddlePhase::Idle); + assert!( + hs.audio_ws_cancel.is_none(), + "nothing installed on an idle huddle" + ); + assert!(hs.audio_relay_pcm_tx.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn replacement_pipeline_dying_before_install_is_redialed_not_installed() { + let state = live_huddle_state(); + let installed = Arc::new(std::sync::Mutex::new(Vec::::new())); + let handed_out = Arc::clone(&installed); + + run(&state, target(&state), move |_| { + let (cancel, pcm_tx) = connection(); + let mut h = handed_out.lock().unwrap(); + if h.is_empty() { + // Auth and join succeed, then the spawned pipeline exits before + // the loop installs it: the pipeline cancels its own token and + // its disconnect callback is refused by `claim_reconnect`. + cancel.cancel(); + } + h.push(cancel.clone()); + std::future::ready(Ok((cancel, pcm_tx))) + }) + .await; + + let installed = installed.lock().unwrap(); + assert_eq!(installed.len(), 2, "dead replacement must be redialed"); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.audio_link, AudioLink::Live); + let live = hs.audio_ws_cancel.as_ref().expect("socket installed"); + assert!(!live.is_cancelled(), "Live must never hold a dead pipeline"); + assert!( + hs.audio_relay_pcm_tx.is_some(), + "sender belongs to the live pipeline" + ); +} + +#[tokio::test(start_paused = true)] +async fn duplicate_disconnect_signals_do_not_start_a_second_loop() { + let state = live_huddle_state(); + let dials = Arc::new(AtomicU32::new(0)); + let started = Instant::now(); + + let first_state = Arc::clone(&state); + let first_dials = Arc::clone(&dials); + let first = tokio::spawn(async move { + run( + &first_state, + target(&first_state), + flaky_dial(first_dials, started, Some(Duration::from_secs(3))), + ) + .await; + }); + tokio::task::yield_now().await; + + let second_dials = Arc::new(AtomicU32::new(0)); + run( + &state, + target(&state), + flaky_dial(Arc::clone(&second_dials), started, None), + ) + .await; + assert_eq!( + second_dials.load(Ordering::SeqCst), + 0, + "second loop must not dial" + ); + + first.await.expect("first loop"); + assert_eq!(audio_link(&state), AudioLink::Live); +} + +#[tokio::test(start_paused = true)] +async fn exhausting_the_window_marks_audio_lost_and_ends_the_loop() { + let state = live_huddle_state(); + let dials = Arc::new(AtomicU32::new(0)); + let started = Instant::now(); + + tokio::time::timeout( + RECONNECT_WINDOW * 2, + run( + &state, + target(&state), + flaky_dial(Arc::clone(&dials), started, None), + ), + ) + .await + .expect("loop must terminate (no task leak)"); + + assert_eq!( + started.elapsed(), + RECONNECT_WINDOW, + "backoff must not overshoot" + ); + let hs = state.huddle().expect("huddle lock"); + assert_eq!(hs.audio_link, AudioLink::Lost); + assert_eq!(hs.phase, HuddlePhase::Active, "still joined; user decides"); + assert!(hs.audio_relay_pcm_tx.is_none()); + let n = dials.load(Ordering::SeqCst); + assert!((12..=300).contains(&n), "{n} dials in the window"); +} + +#[tokio::test(start_paused = true)] +async fn not_live_huddle_is_ignored() { + let state = Arc::new(build_app_state()); + let dials = Arc::new(AtomicU32::new(0)); + run( + &state, + target(&state), + flaky_dial(Arc::clone(&dials), Instant::now(), None), + ) + .await; + assert_eq!(dials.load(Ordering::SeqCst), 0); + assert_eq!(audio_link(&state), AudioLink::Live); +} + +#[test] +fn backoff_grows_caps_and_jitters_within_half_to_full() { + assert_eq!(backoff_delay(1, 1.0), Duration::from_millis(250)); + assert_eq!(backoff_delay(2, 1.0), Duration::from_millis(500)); + assert_eq!(backoff_delay(6, 1.0), Duration::from_secs(5)); + assert_eq!( + backoff_delay(40, 1.0), + Duration::from_secs(5), + "no overflow" + ); + assert_eq!(backoff_delay(1, 0.0), Duration::from_millis(125)); + assert_eq!(backoff_delay(1, 7.0), Duration::from_millis(250), "clamped"); +} + +#[test] +fn audio_link_serializes_as_tagged_status() { + let live = serde_json::to_value(AudioLink::Live).unwrap(); + assert_eq!(live, serde_json::json!({ "status": "live" })); + let reconnecting = serde_json::to_value(AudioLink::Reconnecting { + attempt: 2, + draining: true, + }) + .unwrap(); + assert_eq!( + reconnecting, + serde_json::json!({ "status": "reconnecting", "attempt": 2, "draining": true }) + ); +} + +#[tokio::test(start_paused = true)] +async fn hung_dials_stop_at_the_recovery_deadline() { + let state = live_huddle_state(); + let started = Instant::now(); + let dials = Arc::new(AtomicU32::new(0)); + let calls = Arc::clone(&dials); + tokio::time::timeout( + RECONNECT_WINDOW + Duration::from_secs(1), + run(&state, target(&state), move |_| { + calls.fetch_add(1, Ordering::SeqCst); + std::future::pending::>() + }), + ) + .await + .expect("hung upgrade must be bounded"); + assert_eq!(started.elapsed(), RECONNECT_WINDOW); + assert_eq!(audio_link(&state), AudioLink::Lost); + assert!( + dials.load(Ordering::SeqCst) > 1, + "per-dial cap must allow retries" + ); +} + +#[tokio::test(start_paused = true)] +async fn leave_interrupts_a_hung_dial_immediately() { + let state = live_huddle_state(); + let recovery = run(&state, target(&state), |_| { + std::future::pending::>() + }); + tokio::pin!(recovery); + tokio::select! { + _ = &mut recovery => panic!("dial should be pending"), + _ = tokio::task::yield_now() => {}, + } + let leaving = Instant::now(); + state.huddle().unwrap().reset_preserving_generation(); + tokio::time::timeout(Duration::from_millis(1), recovery) + .await + .expect("leave must wake pending recovery"); + assert_eq!(leaving.elapsed(), Duration::ZERO); + assert_eq!(state.huddle().unwrap().phase, HuddlePhase::Idle); +} + +#[tokio::test(start_paused = true)] +async fn stale_disconnect_cannot_claim_a_replacement_on_the_same_channel() { + let state = live_huddle_state(); + let stale = target(&state); + let replacement = CancellationToken::new(); + { + let mut hs = state.huddle().unwrap(); + hs.begin_huddle_lifetime(); + hs.audio_ws_cancel = Some(replacement.clone()); + } + run(&state, stale, |_| { + panic!("stale callback must never dial"); + #[allow(unreachable_code)] + std::future::ready(Ok(connection())) + }) + .await; + assert_eq!(audio_link(&state), AudioLink::Live); + assert!(!replacement.is_cancelled()); +} + +#[tokio::test(start_paused = true)] +async fn disconnect_identity_checks_channel_and_generation_even_with_a_live_token() { + let state = live_huddle_state(); + for wrong_channel in [false, true] { + let mut stale = target(&state); + if wrong_channel { + stale.ephemeral_channel_id = "other-channel".into(); + } else { + stale.huddle_generation = stale.huddle_generation.wrapping_sub(1); + } + run(&state, stale, |_| { + panic!("mismatched callback must not dial"); + #[allow(unreachable_code)] + std::future::ready(Ok(connection())) + }) + .await; + assert_eq!(audio_link(&state), AudioLink::Live); + } +} + +#[test] +fn huddle_lifetime_cancellation_tracks_begin_reset_not_transcription() { + let state = live_huddle_state(); + let first = target(&state); + { + let mut hs = state.huddle().unwrap(); + hs.invalidate_transcription_pipeline(); + assert!( + !first.lifetime.is_cancelled(), + "transcription is not the huddle lifetime" + ); + hs.begin_huddle_lifetime(); + } + assert!( + first.lifetime.is_cancelled(), + "new lifetime must cancel pending old work" + ); + let second = target(&state); + assert!(!second.lifetime.is_cancelled()); + state.huddle().unwrap().reset_preserving_generation(); + assert!( + second.lifetime.is_cancelled(), + "reset must wake pending work" + ); +} diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 190397aa054..c6700e3a590 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -69,13 +69,76 @@ fn build_audio_auth_event( .map_err(|e| format!("sign: {e}")) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AudioRelayConnectError { + code: Option, + message: String, +} + +impl AudioRelayConnectError { + pub(crate) fn code(&self) -> Option<&str> { + self.code.as_deref() + } + + pub(crate) fn from_relay_payload(value: &serde_json::Value) -> Self { + Self { + code: value["code"].as_str().map(str::to_string), + message: value["message"] + .as_str() + .unwrap_or("unknown relay error") + .to_string(), + } + } +} + +impl From for AudioRelayConnectError { + fn from(message: String) -> Self { + Self { + code: None, + message, + } + } +} + +impl From<&str> for AudioRelayConnectError { + fn from(message: &str) -> Self { + message.to_string().into() + } +} + +impl std::fmt::Display for AudioRelayConnectError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.code.as_deref() { + Some(code) => write!( + formatter, + "audio relay auth error [{code}]: {}", + self.message + ), + None => formatter.write_str(&self.message), + } + } +} + +fn format_audio_relay_error(value: &serde_json::Value) -> AudioRelayConnectError { + AudioRelayConnectError::from_relay_payload(value) +} + +fn parse_audio_roster_peer(peer: &serde_json::Value) -> Option<(u8, String, u8)> { + Some(( + u8::try_from(peer["peer_index"].as_u64()?).ok()?, + peer["pubkey"].as_str()?.to_string(), + // Legacy relays omit the occupancy epoch. + u8::try_from(peer["epoch"].as_u64().unwrap_or(0)).ok()?, + )) +} + async fn connect_authenticated_audio_socket( channel_id: &str, parent_channel_id: Option<&str>, relay_url: &str, keys: &nostr::Keys, auth_tag_json: Option<&str>, -) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { +) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), AudioRelayConnectError> { use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); @@ -134,19 +197,7 @@ async fn connect_authenticated_audio_socket( let peers = value["peers"] .as_array() .map(|peers| { - peers - .iter() - .filter_map(|peer| { - Some(( - peer["peer_index"].as_u64()? as u8, - peer["pubkey"].as_str()?.to_string(), - // Absent `epoch` (legacy relay) degrades - // to 0 so the fence becomes a no-op rather - // than rejecting every frame. - peer["epoch"].as_u64().unwrap_or(0) as u8, - )) - }) - .collect() + peers.iter().filter_map(parse_audio_roster_peer).collect() }) .unwrap_or_default(); let peer_index = value["peer_index"] @@ -156,7 +207,7 @@ async fn connect_authenticated_audio_socket( break Ok((peer_index, peers)); } Some("error") => { - break Err(format!("audio relay auth error: {}", value["message"])); + break Err(format_audio_relay_error(&value)); } _ => continue, } @@ -182,7 +233,7 @@ pub(crate) async fn connect_audio_relay( channel_id: &str, parent_channel_id: Option<&str>, state: &AppState, -) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { +) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), AudioRelayConnectError> { let relay_url = crate::relay::relay_ws_url_with_override(state); let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); @@ -194,6 +245,7 @@ pub(crate) async fn connect_audio_relay( remote_stt_pipeline, agent_pubkeys, human_floor, + reconnect_target, ) = { let hs = state.huddle()?; ( @@ -202,7 +254,8 @@ pub(crate) async fn connect_audio_relay( Arc::clone(&hs.local_tts_publishers), Arc::clone(&hs.remote_stt_pipeline), Arc::clone(&hs.agent_pubkeys), - hs.human_floor.clone(), + hs.human_floor.for_audio_connection(), + super::reconnect::ReconnectTarget::capture(&hs, channel_id, parent_channel_id), ) }; @@ -215,12 +268,8 @@ pub(crate) async fn connect_audio_relay( let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); - let output_device_name = state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); + let output_device_changes = state.huddle_audio.output_device_changes.subscribe(); + let output_device_name = output_device_changes.borrow().clone(); tokio::spawn(async move { if let Err(e) = audio_relay_pipeline(AudioRelayPipelineArgs { @@ -237,19 +286,22 @@ pub(crate) async fn connect_audio_relay( agent_pubkeys, human_floor, output_device_name, + output_device_changes, }) .await { eprintln!("buzz-desktop: audio relay pipeline exited: {e}"); } - // Only emit the disconnect event for UNEXPECTED exits. - // Skip if already cancelled (teardown_huddle in progress). + // Only UNEXPECTED exits reconnect. An already-cancelled token means + // teardown_huddle is in progress and the huddle is going away. + // Cancelling before the reconnect call is load-bearing: if this + // pipeline dies before a running reconnect loop has installed its + // handles, the loop reads the cancelled token as a failed dial. if !cancel_clone.is_cancelled() { cancel_clone.cancel(); - if let Some(ref app) = app_handle { - use tauri::Emitter; - let _ = app.emit("huddle-audio-disconnected", ()); + if let Some(app) = app_handle { + super::reconnect::after_unexpected_disconnect(app, reconnect_target).await; } } }); @@ -265,6 +317,75 @@ type WsReceiver = futures_util::stream::SplitStream; const TTS_BROADCAST_QUEUE_DEPTH: usize = 8; const TTS_BROADCAST_MAX_FRAMES: usize = 1_500; // 30 seconds at 20 ms/frame. +const AUDIO_SEND_QUEUE_DEPTH: usize = 4; + +#[derive(Default)] +struct AudioSendQueueState { + frames: std::collections::VecDeque>, + closed: bool, +} + +#[derive(Default)] +struct AudioSendQueue { + state: std::sync::Mutex, + ready: tokio::sync::Notify, +} + +impl AudioSendQueue { + fn push_latest(&self, frame: Vec) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.closed { + return; + } + if state.frames.len() == AUDIO_SEND_QUEUE_DEPTH { + state.frames.pop_front(); + } + state.frames.push_back(frame); + drop(state); + self.ready.notify_one(); + } + + fn close(&self) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .closed = true; + self.ready.notify_waiters(); + } + + async fn pop(&self) -> Option> { + loop { + let notified = self.ready.notified(); + { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(frame) = state.frames.pop_front() { + return Some(frame); + } + if state.closed { + return None; + } + } + notified.await; + } + } +} + +async fn wire_send_loop( + queue: std::sync::Arc, + sink: std::sync::Arc>, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, + S::Error: std::fmt::Display, +{ + while let Some(frame) = queue.pop().await { + let mut sink = sink.lock().await; + sink.send(WsMsg::Binary(frame.into())) + .await + .map_err(|error| format!("audio send: {error}"))?; + } + Ok(()) +} struct QueuedTtsFrame { epoch: u64, @@ -329,7 +450,8 @@ pub(crate) async fn connect_tts_audio_publisher( keys, auth_tag_json, ) - .await?; + .await + .map_err(|error| error.to_string())?; let cancel = CancellationToken::new(); let publisher_cancel = cancel.clone(); @@ -462,6 +584,7 @@ struct AudioRelayPipelineArgs { agent_pubkeys: Arc>>, human_floor: super::human_floor::HumanFloor, output_device_name: Option, + output_device_changes: tokio::sync::watch::Receiver>, } async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String> { @@ -479,12 +602,13 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String agent_pubkeys, human_floor, output_device_name, + output_device_changes, } = args; let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip) .map_err(|e| format!("opus encoder: {e}"))?; encoder - .set_bitrate(opus::Bitrate::Bits(32000)) + .set_bitrate(opus::Bitrate::Bits(32_000)) .map_err(|e| format!("opus bitrate: {e}"))?; encoder .set_dtx(true) @@ -496,8 +620,13 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String let ws_tx = StdArc::new(tokio::sync::Mutex::new(ws_tx)); let ws_tx_send = StdArc::clone(&ws_tx); let cancel_send = cancel.clone(); + let send_queue = StdArc::new(AudioSendQueue::default()); + let wire_queue = StdArc::clone(&send_queue); - let send_task = tokio::spawn(async move { + let wire_send_task = tokio::spawn(wire_send_loop(wire_queue, ws_tx_send)); + + let encode_queue = StdArc::clone(&send_queue); + let encode_task = tokio::spawn(async move { use super::wire::{audio_level_dbov, FrameHeader, V2_HEADER_LEN}; let mut encoder = encoder; // Move encoder into task. const FRAME_SAMPLES: usize = 960; @@ -528,7 +657,6 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) .collect(); - let mut tx = ws_tx_send.lock().await; for chunk in samples.chunks(FRAME_SAMPLES) { // dBov is computed from the pre-encode PCM. Opus DTX may // produce a 1-2 byte comfort packet; computing level from @@ -565,24 +693,21 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String let mut frame = Vec::with_capacity(V2_HEADER_LEN + n); frame.extend_from_slice(&header); frame.extend_from_slice(&out_buf[..n]); - if tx.send(WsMsg::Binary(frame.into())).await.is_err() { - return; // WS closed. - } + encode_queue.push_latest(frame); seq = seq.wrapping_add(1); ts_48k = ts_48k.wrapping_add(super::jitter::FRAME_TIMESTAMP_DELTA); } } } - let mut tx = ws_tx_send.lock().await; - let _ = tx.send(WsMsg::Close(None)).await; + encode_queue.close(); }); let recv_task = tokio::spawn(super::playout::run_playout_recv_loop( ws_rx, ws_tx, sink_handle, - cancel, + cancel.clone(), app_handle, initial_peers, tts_active, @@ -590,17 +715,61 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String local_tts_publishers, remote_stt_pipeline, agent_pubkeys, - human_floor, + human_floor.clone(), + output_device_changes, )); - // Wait for either task to finish, then abort the survivor. - use futures_util::future::Either; - match futures_util::future::select(std::pin::pin!(send_task), std::pin::pin!(recv_task)).await { - Either::Left((_, recv_handle)) => recv_handle.abort(), - Either::Right((_, send_handle)) => send_handle.abort(), + supervise_audio_tasks( + encode_task, + wire_send_task, + recv_task, + &send_queue, + &cancel, + &human_floor, + ) + .await +} + +/// Every exit, including a child panic, joins the remaining children before +/// clearing this connection's remote floor or handing recovery to a successor. +async fn supervise_audio_tasks( + mut encode: tokio::task::JoinHandle<()>, + mut send: tokio::task::JoinHandle>, + mut recv: tokio::task::JoinHandle<()>, + queue: &AudioSendQueue, + cancel: &CancellationToken, + floor: &super::human_floor::HumanFloor, +) -> Result<(), String> { + let (completed, result) = tokio::select! { + result = &mut encode => (0, result.map_err(|error| format!("audio encode task: {error}"))), + result = &mut send => (1, result.map_err(|error| format!("audio send task: {error}")).and_then(|result| result)), + result = &mut recv => (2, result.map_err(|error| format!("audio receive task: {error}"))), + _ = cancel.cancelled() => (3, Ok(())), + }; + queue.close(); + encode.abort(); + send.abort(); + recv.abort(); + // A completed JoinHandle must not be polled a second time. + if completed != 0 { + log_audio_child_join("encode", encode.await); + } + if completed != 1 { + log_audio_child_join("send", send.await); } + if completed != 2 { + log_audio_child_join("receive", recv.await); + } + floor.clear_remote(); + result +} - Ok(()) +fn log_audio_child_join(name: &str, result: Result) { + if let Err(error) = result { + if !error.is_cancelled() { + eprintln!("buzz-desktop: audio {name} task failed during teardown: {error}"); + } + } } /// Fetch channel members with roles from the relay. Returns (pubkey, role) tuples. @@ -671,43 +840,5 @@ pub(crate) async fn count_human_members( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tts_upsampling_doubles_rate_with_linear_midpoints() { - assert_eq!( - upsample_tts_24k_to_48k(&[0.0, 1.0, -1.0]), - vec![0.0, 0.5, 1.0, 0.0, -1.0, -1.0] - ); - } - - #[test] - fn tts_queue_rejects_cancelled_versions_and_pads_twenty_ms_frames() { - let mut queue = std::collections::VecDeque::new(); - queue_tts_broadcast_packet( - &mut queue, - super::super::tts::TtsBroadcastPacket { - epoch: 1, - speaker_generation: 7, - samples_24k: vec![0.25; 480], - }, - 1, - 7, - ); - assert_eq!(queue.len(), 1); - assert_eq!(queue[0].samples_48k.len(), 960); - - queue_tts_broadcast_packet( - &mut queue, - super::super::tts::TtsBroadcastPacket { - epoch: 1, - speaker_generation: 7, - samples_24k: vec![0.5; 480], - }, - 2, - 7, - ); - assert_eq!(queue.len(), 1, "cancelled epoch must not enqueue"); - } -} +#[path = "relay_api_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/relay_api_tests.rs b/desktop/src-tauri/src/huddle/relay_api_tests.rs new file mode 100644 index 00000000000..de844f083cb --- /dev/null +++ b/desktop/src-tauri/src/huddle/relay_api_tests.rs @@ -0,0 +1,312 @@ +use super::*; + +#[test] +fn relay_auth_errors_preserve_stable_codes_for_ui_mapping() { + for (code, message) in [ + ("room_full", "room participant capacity reached"), + ("room_ended", "huddle has ended"), + ("huddle_relay_draining", "relay is draining; reconnect"), + ( + "huddle_owner_unreachable", + "could not reach the huddle owner", + ), + ("unsupported_version", "unsupported audio protocol version"), + ("upgrade_required", "audio protocol upgrade required"), + ] { + let payload = serde_json::json!({ + "type": "error", + "code": code, + "message": message, + }); + let error = format_audio_relay_error(&payload); + assert_eq!(error.code(), Some(code)); + assert_eq!( + error.to_string(), + format!("audio relay auth error [{code}]: {message}") + ); + } +} + +#[test] +fn audio_send_queue_drops_oldest_frame_when_full() { + let queue = AudioSendQueue::default(); + for value in 0..=AUDIO_SEND_QUEUE_DEPTH as u8 { + queue.push_latest(vec![value]); + } + let frames = queue + .state + .lock() + .expect("queue") + .frames + .iter() + .cloned() + .collect::>(); + assert_eq!(frames, vec![vec![1], vec![2], vec![3], vec![4]]); +} + +#[tokio::test] +async fn audio_send_queue_close_wakes_waiter_and_rejects_new_frames() { + let queue = std::sync::Arc::new(AudioSendQueue::default()); + let waiting_queue = std::sync::Arc::clone(&queue); + let waiter = tokio::spawn(async move { waiting_queue.pop().await }); + tokio::task::yield_now().await; + + queue.close(); + assert_eq!(waiter.await.expect("waiter"), None); + queue.push_latest(vec![1]); + assert_eq!(queue.pop().await, None); +} + +#[tokio::test] +async fn audio_send_queue_drains_before_reporting_closed() { + let queue = AudioSendQueue::default(); + queue.push_latest(vec![1]); + queue.close(); + + assert_eq!(queue.pop().await, Some(vec![1])); + assert_eq!(queue.pop().await, None); +} + +#[tokio::test] +async fn wire_send_failure_is_preserved_for_pipeline_owner() { + struct FailingSink; + impl futures_util::Sink for FailingSink { + type Error = &'static str; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Err("socket closed")) + } + fn start_send(self: std::pin::Pin<&mut Self>, _item: WsMsg) -> Result<(), Self::Error> { + Err("socket closed") + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } + + let queue = std::sync::Arc::new(AudioSendQueue::default()); + queue.push_latest(vec![1]); + let result = wire_send_loop( + queue, + std::sync::Arc::new(tokio::sync::Mutex::new(FailingSink)), + ) + .await; + assert!( + result.is_err_and(|error| error == "audio send: socket closed"), + "the reconnect owner must receive the socket send failure" + ); +} + +#[test] +fn tts_upsampling_doubles_rate_with_linear_midpoints() { + assert_eq!( + upsample_tts_24k_to_48k(&[0.0, 1.0, -1.0]), + vec![0.0, 0.5, 1.0, 0.0, -1.0, -1.0] + ); +} + +#[test] +fn tts_queue_rejects_cancelled_versions_and_pads_twenty_ms_frames() { + let mut queue = std::collections::VecDeque::new(); + queue_tts_broadcast_packet( + &mut queue, + super::super::tts::TtsBroadcastPacket { + epoch: 1, + speaker_generation: 7, + samples_24k: vec![0.25; 480], + }, + 1, + 7, + ); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].samples_48k.len(), 960); + + queue_tts_broadcast_packet( + &mut queue, + super::super::tts::TtsBroadcastPacket { + epoch: 1, + speaker_generation: 7, + samples_24k: vec![0.5; 480], + }, + 2, + 7, + ); + assert_eq!(queue.len(), 1, "cancelled epoch must not enqueue"); +} + +#[test] +fn authenticated_roster_parsing_checks_routing_bounds() { + let peer = serde_json::json!({"pubkey": "agent", "peer_index": 7, "epoch": 3}); + assert_eq!(parse_audio_roster_peer(&peer), Some((7, "agent".into(), 3))); + let legacy = serde_json::json!({"pubkey": "human", "peer_index": 8}); + assert_eq!( + parse_audio_roster_peer(&legacy), + Some((8, "human".into(), 0)) + ); + for field in ["peer_index", "epoch"] { + let mut invalid = peer.clone(); + invalid[field] = 256.into(); + assert_eq!(parse_audio_roster_peer(&invalid), None, "{field}"); + } +} + +// Drop records are set by the actual spawned children, not by the supervisor. +// Awaiting supervisor completion must imply every child has released ownership. +struct ChildDrop { + finished: Arc, + floor: Option, +} +impl Drop for ChildDrop { + fn drop(&mut self) { + if let Some(floor) = &self.floor { + // Model a final write during child teardown. Cleanup must run AFTER + // this, not merely schedule an abort and clear the shared state. + floor.enter_remote(9); + } + self.finished + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } +} + +#[tokio::test] +async fn supervisor_joins_children_and_clears_only_its_connection_on_every_exit() { + for exit in [ + "encode", + "send", + "receive", + "encode-panic", + "send-panic", + "receive-panic", + "cancel", + ] { + let root = super::super::human_floor::HumanFloor::new(); + let old = root.for_audio_connection(); + let replacement = root.for_audio_connection(); + old.enter_remote(7); + replacement.enter_remote(7); // same routing index, different socket + root.enter_local(true, true); + let finished = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let encode_drop = ChildDrop { + finished: Arc::clone(&finished), + floor: None, + }; + let send_drop = ChildDrop { + finished: Arc::clone(&finished), + floor: None, + }; + let recv_drop = ChildDrop { + finished: Arc::clone(&finished), + floor: Some(old.clone()), + }; + let (encode_tx, encode_rx) = tokio::sync::oneshot::channel::(); + let (send_tx, send_rx) = tokio::sync::oneshot::channel::(); + let (recv_tx, recv_rx) = tokio::sync::oneshot::channel::(); + let encode = tokio::spawn(async move { + let _guard = encode_drop; + assert!(!encode_rx.await.unwrap(), "encode panic"); + }); + let send = tokio::spawn(async move { + let _guard = send_drop; + assert!(!send_rx.await.unwrap(), "send panic"); + Err("socket failed".into()) + }); + let recv = tokio::spawn(async move { + let _guard = recv_drop; + assert!(!recv_rx.await.unwrap(), "receive panic"); + }); + let cancel = CancellationToken::new(); + match exit { + "encode" => encode_tx.send(false).unwrap(), + "send" => send_tx.send(false).unwrap(), + "receive" => recv_tx.send(false).unwrap(), + "encode-panic" => encode_tx.send(true).unwrap(), + "send-panic" => send_tx.send(true).unwrap(), + "receive-panic" => recv_tx.send(true).unwrap(), + _ => cancel.cancel(), + } + let queue = AudioSendQueue::default(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + supervise_audio_tasks(encode, send, recv, &queue, &cancel, &old), + ) + .await + .expect("supervisor must terminate"); + assert_eq!( + result.is_err(), + exit == "send" || exit.ends_with("panic"), + "{exit}: {result:?}" + ); + assert_eq!( + finished.load(std::sync::atomic::Ordering::SeqCst), + 3, + "{exit}: unjoined child" + ); + assert!( + queue.state.lock().unwrap().closed, + "{exit}: queue left open" + ); + // Old cleanup must preserve both replacement and local ownership. + replacement.leave_remote(7); + assert!(root.is_blocked(), "{exit}: local floor was erased"); + root.leave_local(); + assert!(!root.is_blocked(), "{exit}: old remote floor leaked"); + replacement.enter_remote(7); + old.clear_remote(); + assert!( + root.is_blocked(), + "{exit}: stale cleanup erased replacement" + ); + replacement.clear_remote(); + assert!(!root.is_blocked()); + } +} + +#[tokio::test] +async fn supervisor_waits_for_receiver_drop_when_other_children_already_finished() { + let root = super::super::human_floor::HumanFloor::new(); + let floor = root.for_audio_connection(); + let finished = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let drop_guard = ChildDrop { + finished: Arc::clone(&finished), + floor: Some(floor.clone()), + }; + let encode = tokio::spawn(async {}); + let send = tokio::spawn(async { Ok(()) }); + let recv = tokio::spawn(async move { + let _guard = drop_guard; + std::future::pending::<()>().await; + }); + tokio::task::yield_now().await; + assert!(encode.is_finished() && send.is_finished()); + supervise_audio_tasks( + encode, + send, + recv, + &AudioSendQueue::default(), + &CancellationToken::new(), + &floor, + ) + .await + .unwrap(); + assert_eq!( + finished.load(std::sync::atomic::Ordering::SeqCst), + 1, + "receiver must have dropped before supervisor returns" + ); + assert!( + !root.is_blocked(), + "cleanup follows the receiver's last write" + ); +} diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index c7aff1bf7e2..ac7e81bce7c 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -43,9 +43,28 @@ pub enum HuddlePhase { Leaving, } +/// Health of the audio relay socket, independent of `phase`. +/// +/// `phase` stays `Active` while the socket is rebuilt so STT/TTS/agent voice +/// (which gate on `Connected | Active`) keep running through a blip. Only the +/// audio transport is being recovered; see `reconnect.rs`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AudioLink { + #[default] + Live, + /// Rust is redialing the audio relay. `attempt` counts failed dials so far; + /// `draining` is true when the last refusal was `huddle_relay_draining`. + Reconnecting { attempt: u32, draining: bool }, + /// The retry window elapsed without a successful dial. The huddle is still + /// joined but carries no audio; the user must leave or rejoin. + Lost, +} + #[derive(Debug, Serialize, Deserialize)] pub struct HuddleState { pub phase: HuddlePhase, + pub audio_link: AudioLink, pub parent_channel_id: Option, pub ephemeral_channel_id: Option, /// Root event for the huddle's visible parent-channel thread. Transcript @@ -140,6 +159,9 @@ pub struct HuddleState { /// generation, this changes only when a new start/join attempt begins. #[serde(skip)] pub huddle_generation: u64, + /// Ends recovery even while a dial is pending; distinct from one socket. + #[serde(skip)] + pub(crate) huddle_cancel: tokio_util::sync::CancellationToken, /// Session generation — incremented on every teardown. The transcription /// task captures this at spawn time and checks before each POST. If the /// generation has changed, the task silently drops the transcript. @@ -189,6 +211,7 @@ impl Clone for HuddleState { .clone(); Self { phase: self.phase.clone(), + audio_link: self.audio_link.clone(), parent_channel_id: self.parent_channel_id.clone(), ephemeral_channel_id: self.ephemeral_channel_id.clone(), huddle_thread_event_id: self.huddle_thread_event_id.clone(), @@ -212,6 +235,7 @@ impl Clone for HuddleState { stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, huddle_generation: self.huddle_generation, + huddle_cancel: self.huddle_cancel.clone(), session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), @@ -226,6 +250,7 @@ impl Default for HuddleState { let human_floor = HumanFloor::new(); Self { phase: HuddlePhase::Idle, + audio_link: AudioLink::Live, parent_channel_id: None, ephemeral_channel_id: None, huddle_thread_event_id: None, @@ -249,6 +274,7 @@ impl Default for HuddleState { stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, huddle_generation: 0, + huddle_cancel: tokio_util::sync::CancellationToken::new(), session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), @@ -276,6 +302,8 @@ impl HuddleState { /// Begin a new local huddle lifetime and return its identity. pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 { + self.huddle_cancel.cancel(); + self.huddle_cancel = tokio_util::sync::CancellationToken::new(); self.huddle_generation = self.huddle_generation.wrapping_add(1); self.huddle_generation } @@ -346,6 +374,7 @@ impl HuddleState { /// Used by start_huddle rollback, join_huddle rollback, and teardown_huddle /// to invalidate in-flight transcription tasks without losing the generation. pub(crate) fn reset_preserving_generation(&mut self) { + self.huddle_cancel.cancel(); let gen = Arc::clone(&self.session_generation); let huddle_generation = self.huddle_generation; let tts_enabled = self.tts_enabled; diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index c27bf38b649..3d6c35db2b2 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -100,7 +100,7 @@ impl SttPipeline { ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -120,7 +120,7 @@ impl SttPipeline { ptt_active_worker, manual_mic_unmuted_worker, human_floor, - output_device, + output_device_changes, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -449,7 +449,7 @@ fn stt_worker( ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) { // ── 1. Initialise sherpa-onnx recognizer ───────────────────────────────── // @@ -566,7 +566,7 @@ fn stt_worker( manual_gate, &human_floor, local_barge_in_state, - output_device.as_deref(), + output_device_changes.borrow().as_deref(), track_local_floor, ); } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 3f12f883ba7..a1ca712e719 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -200,7 +200,7 @@ impl TtsPipeline { cancel: Arc, human_floor: HumanFloor, voice: &str, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); @@ -255,7 +255,7 @@ impl TtsPipeline { worker_playback_probe, worker_broadcasters, ), - output_device, + output_device_changes, activity_app, startup_tx, ) @@ -318,6 +318,19 @@ fn authorize_or_defer_queued_text( } } +pub(super) fn apply_pending_output_device_change( + output_device_changes: &mut tokio::sync::watch::Receiver>, + mut apply: impl FnMut(Option<&str>) -> Result<(), String>, +) { + if !output_device_changes.has_changed().unwrap_or(false) { + return; + } + let selected = output_device_changes.borrow_and_update().clone(); + if let Err(error) = apply(selected.as_deref()) { + eprintln!("buzz-desktop: live TTS output device switch failed: {error}"); + } +} + #[allow(clippy::too_many_arguments)] fn tts_worker( model_dir: PathBuf, @@ -325,7 +338,7 @@ fn tts_worker( text_rx: mpsc::Receiver, human_floor: HumanFloor, control_state: WorkerControlState, - output_device: Option, + mut output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, startup_tx: mpsc::SyncSender>, ) { @@ -397,16 +410,17 @@ fn tts_worker( // ── 3. Initialise rodio output device ───────────────────────────────────── use rodio::buffer::SamplesBuffer; - let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref()) - { - Ok(h) => h, - Err(e) => { - let error = format!("TTS audio output initialization failed: {e}"); - eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); - let _ = startup_tx.send(Err(error)); - return; - } - }; + let initial_output_device = output_device_changes.borrow().clone(); + let mut sink_handle = + match super::audio_output::open_output_sink_by_name(initial_output_device.as_deref()) { + Ok(h) => h, + Err(e) => { + let error = format!("TTS audio output initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); + let _ = startup_tx.send(Err(error)); + return; + } + }; let channels = match NonZero::new(1u16) { Some(c) => c, @@ -505,11 +519,17 @@ fn tts_worker( channels, rate, }; - let append_audio = |prepared: PreparedModelAudio, - route_id: u64, - speaker_pubkey: Option<&str>, - speaker_generation: u64, - floor_epoch: u64| { + let mut append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>, + speaker_generation: u64, + floor_epoch: u64| { + apply_pending_output_device_change(&mut output_device_changes, |selected| { + let next_sink = super::audio_output::open_output_sink_by_name(selected)?; + playback.replace_output_mixer(next_sink.mixer()); + sink_handle = next_sink; + Ok(()) + }); let broadcast_samples = speaker_pubkey.map(|_| prepared.buffer.clone()); append_worker_audio( &append_context, diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs index 8a90c018994..b6b1fe049da 100644 --- a/desktop/src-tauri/src/huddle/tts_playback.rs +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -75,7 +75,7 @@ pub(super) enum HumanFloorAuthorization { struct HumanFloorState { epoch: u64, local: bool, - remote: HashSet, + remote: HashSet<(uuid::Uuid, u8)>, } pub(super) struct SynthesisFlightGuard { @@ -122,6 +122,17 @@ impl PlaybackCoordinator { } } + pub(super) fn replace_output_mixer(&self, mixer: &Mixer) { + *self.mixer.lock().unwrap_or_else(PoisonError::into_inner) = Some(mixer.clone()); + let old_player = { + let mut state = self.lock(); + state.first_append = true; + state.output_lease.begin_hangover(Instant::now()); + state.player.replace(Player::connect_new(mixer)) + }; + drop(old_player); + } + fn lock(&self) -> MutexGuard<'_, PlaybackState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -337,16 +348,19 @@ impl PlaybackCoordinator { self.lock().human_floor.local = false; } - pub(super) fn enter_remote_human_floor(&self, peer: u8) { - self.enter_human_floor(|floor| floor.remote.insert(peer)); + pub(super) fn enter_remote_human_floor(&self, scope: uuid::Uuid, peer: u8) { + self.enter_human_floor(|floor| floor.remote.insert((scope, peer))); } - pub(super) fn leave_remote_human_floor(&self, peer: u8) { - self.lock().human_floor.remote.remove(&peer); + pub(super) fn leave_remote_human_floor(&self, scope: uuid::Uuid, peer: u8) { + self.lock().human_floor.remote.remove(&(scope, peer)); } - pub(super) fn clear_remote_human_floor(&self) { - self.lock().human_floor.remote.clear(); + pub(super) fn clear_remote_human_floor(&self, scope: uuid::Uuid) { + self.lock() + .human_floor + .remote + .retain(|(owner, _)| *owner != scope); } fn enter_human_floor(&self, enter: impl FnOnce(&mut HumanFloorState) -> bool) { @@ -422,6 +436,44 @@ mod tests { ) } + #[test] + fn pending_output_watch_change_replaces_live_playback_before_next_append() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let (output_tx, mut output_rx) = tokio::sync::watch::channel(None); + output_tx.send_replace(Some("new route".to_string())); + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + crate::huddle::tts::apply_pending_output_device_change(&mut output_rx, |selected| { + assert_eq!(selected, Some("new route")); + playback.replace_output_mixer(&next_mixer); + Ok(()) + }); + + assert!(playback.empty(), "the old-route queue must be dropped"); + append_second(&playback); + assert!(!playback.empty(), "subsequent audio must use the new mixer"); + } + + #[test] + fn replacing_output_mixer_drops_old_route_and_accepts_new_audio() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + playback.replace_output_mixer(&next_mixer); + + assert!(playback.empty()); + append_second(&playback); + assert!(!playback.empty()); + } + #[test] fn floor_authorized_append_does_not_reenter_the_coordinator_lock() { let (playback, _unpulled_source) = coordinator(); @@ -573,7 +625,7 @@ mod tests { let (playback, _unpulled_source) = coordinator(); let delayed_tts_epoch = playback.human_floor_epoch(); - playback.enter_remote_human_floor(7); + playback.enter_remote_human_floor(uuid::Uuid::nil(), 7); assert!(playback.human_floor_blocked()); assert!(!playback.human_floor_permits(delayed_tts_epoch)); @@ -584,12 +636,12 @@ mod tests { let (playback, _unpulled_source) = coordinator(); assert!(playback.enter_local_human_floor(true, false)); let local_epoch = playback.human_floor_epoch(); - playback.enter_remote_human_floor(7); + playback.enter_remote_human_floor(uuid::Uuid::nil(), 7); assert_ne!(playback.human_floor_epoch(), local_epoch); playback.leave_local_human_floor(); assert!(playback.human_floor_blocked()); - playback.leave_remote_human_floor(7); + playback.leave_remote_human_floor(uuid::Uuid::nil(), 7); assert!(!playback.human_floor_blocked()); } diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 75cfef26e55..1e96cd6f09a 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -45,6 +45,8 @@ pub struct HuddleAudioSettingsState { pub tts_transition: tokio::sync::Mutex<()>, /// Selected huddle output device. `None` uses the system default. pub output_device: Mutex>, + /// Live output-route changes for an active huddle playout loop. + pub output_device_changes: tokio::sync::watch::Sender>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -624,7 +626,7 @@ pub async fn preview_pocket_voice( cancel, super::human_floor::HumanFloor::new(), &voice_name, - output_device, + tokio::sync::watch::channel(output_device).1, None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fe2bba5024b..07e0f894ed5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -71,7 +71,6 @@ use huddle::{ check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, open_huddle_window, push_audio_pcm, - reconnect::reconnect_huddle_audio, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, @@ -795,7 +794,6 @@ pub fn run() { close_huddle_companion, open_huddle_window, push_audio_pcm, - reconnect_huddle_audio, start_stt_pipeline, set_huddle_transcription_enabled, download_voice_models, diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index 5a3a7c40419..55856079064 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -4,8 +4,10 @@ import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { mergeMessages } from "@/features/messages/hooks"; import { channelWindowThreadSummaries, + flattenChannelWindowEvents, type ChannelWindowStore, } from "@/features/messages/lib/channelWindowStore"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -25,6 +27,19 @@ type HuddleChannelMessagesOptions = { windowStore?: ChannelWindowStore; }; +export function seedHuddleThreadReplies( + windowStore: ChannelWindowStore | undefined, +): ReadonlyMap { + const repliesByRoot = new Map(); + if (!windowStore) return repliesByRoot; + for (const event of flattenChannelWindowEvents(windowStore)) { + const { parentId, rootId } = getThreadReference(event.tags); + if (!parentId || !rootId) continue; + repliesByRoot.set(rootId, [...(repliesByRoot.get(rootId) ?? []), event]); + } + return repliesByRoot; +} + export function useHuddleChannelMessages({ activeChannel, isHuddleTranscript, @@ -51,9 +66,16 @@ export function useHuddleChannelMessages({ : [], [isHuddleTranscript, threadSummaries], ); + const placeholderThreadRepliesByRoot = React.useMemo( + () => seedHuddleThreadReplies(windowStore), + [windowStore], + ); const huddleThreadReplies = useThreadRepliesForRoots( activeChannel, huddleThreadRootIds, + { + placeholderDataByRoot: placeholderThreadRepliesByRoot, + }, ); const resolvedMessages = React.useMemo( () => diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 8e6ccbbd890..86c39adb521 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -814,62 +814,6 @@ export function HuddleProvider({ }; }, [ownsAudioSession]); - // Unexpected audio-owner/pod disconnects are recoverable: keep the huddle, - // mic, and voice pipelines live while Rust reconnects only the audio WS. - // `tokenRef` makes an intentional leave/start supersede this loop, and the - // in-flight guard collapses duplicate disconnect events from failed dials. - const audioReconnectInFlightRef = React.useRef(false); - React.useEffect(() => { - if (!ownsAudioSession) return; - - let cancelled = false; - let unlisten: (() => void) | null = null; - listen("huddle-audio-disconnected", () => { - if (cancelled || audioReconnectInFlightRef.current) return; - audioReconnectInFlightRef.current = true; - const reconnectToken = tokenRef.current; - - void (async () => { - // Keep a long enough tail for Kubernetes Service endpoint removal after - // a draining pod flips readiness. Early retries make remote-owner - // handoff fast; the two 2s attempts prevent a client connected to the - // draining pod itself from exhausting before kube-proxy converges. - const delaysMs = [0, 100, 250, 500, 1_000, 2_000, 2_000]; - for (const delayMs of delaysMs) { - if (cancelled || tokenRef.current !== reconnectToken) return; - if (delayMs > 0) { - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - } - if (cancelled || tokenRef.current !== reconnectToken) return; - try { - await invoke("reconnect_huddle_audio"); - // Success installs a live replacement pipeline. If it later fails, - // its Tauri event arrives after this loop releases the in-flight - // guard and starts a fresh bounded recovery cycle. Repeating those - // cycles is intentional while the relay remains connectable. - return; - } catch { - // A draining pod may still receive the first retry before Service - // endpoints converge. Keep the bounded backoff client-local. - } - } - - if (!cancelled && tokenRef.current === reconnectToken) { - await leaveHuddleRef.current(); - } - })().finally(() => { - audioReconnectInFlightRef.current = false; - }); - }).then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [ownsAudioSession]); - // High-frequency (20-30 Hz) audio levels live in their own context so their // churn re-renders only the meter components, not every useHuddle consumer. const levelsValue = React.useMemo( diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7c..382d2b7eed9 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -22,6 +22,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { getStorageItem, setStorageItem } from "@/shared/lib/safeStorage"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; @@ -29,13 +30,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { useHuddleParticipantRoster } from "../hooks/useHuddleParticipantRoster"; +import { audioLinkNotice, type HuddleAudioLink } from "../lib/audioLink"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; import { HuddleParticipantsControl } from "./ParticipantList"; import { truncatePubkey } from "@/shared/lib/pubkey"; -// Mirrors HuddleState in src-tauri/src/huddle/mod.rs. +// Mirrors HuddleState in src-tauri/src/huddle/state.rs. type HuddleState = { phase: | "idle" @@ -44,6 +46,7 @@ type HuddleState = { | "connected" | "active" | "leaving"; + audio_link: HuddleAudioLink; parent_channel_id: string | null; ephemeral_channel_id: string | null; huddle_thread_event_id: string | null; @@ -71,9 +74,14 @@ const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000; const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000; const HUDDLE_REACTION_NAME_MAX = 48; const HEADPHONES_HINT_SEEN_STORAGE_KEY = "buzz.huddle.headphones-hint-seen"; +const PTT_HINT_SEEN_STORAGE_KEY = "buzz.huddle.ptt-hint-seen"; function hasSeenHeadphonesHint() { - return window.localStorage.getItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; + return getStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; +} + +function hasSeenPttHint() { + return getStorageItem(PTT_HINT_SEEN_STORAGE_KEY) === "1"; } function isVisibleHuddleState(state: HuddleState | null) { @@ -188,6 +196,8 @@ export function HuddleBar({ const [headphonesHintDismissed, setHeadphonesHintDismissed] = React.useState( hasSeenHeadphonesHint, ); + const [pttHintDismissed, setPttHintDismissed] = + React.useState(hasSeenPttHint); const [isLeaving, setIsLeaving] = React.useState(false); const [showAddAgent, setShowAddAgent] = React.useState(false); const [agentAddError, setAgentAddError] = React.useState(null); @@ -330,9 +340,13 @@ export function HuddleBar({ const mainHadActiveHuddleRef = React.useRef(false); const dismissHeadphonesHint = React.useCallback(() => { - window.localStorage.setItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); + setStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); setHeadphonesHintDismissed(true); }, []); + const dismissPttHint = React.useCallback(() => { + setStorageItem(PTT_HINT_SEEN_STORAGE_KEY, "1"); + setPttHintDismissed(true); + }, []); React.useEffect(() => { onVisibilityChange?.(isHuddleVisible); @@ -502,6 +516,7 @@ export function HuddleBar({ const hasAvailableMic = micConnected; const ttsEnabled = barState.tts_enabled; const transcriptionEnabled = barState.transcription_enabled; + const audioLinkBanner = audioLinkNotice(barState.audio_link); // Self-removing detection: remote-peer audio plays through native rodio // today (outside the WebView render graph), so the browser's AEC has no // far-end reference. The AEC follow-up PR flips this constant in the @@ -582,6 +597,28 @@ export function HuddleBar({ )} >
+ {/* Audio link banner — Rust is redialing, or gave up. */} + {audioLinkBanner && ( + + + {audioLinkBanner.message} + + + )} + {/* Error banner */} {huddleError && (
void; isMuted: boolean; onToggleMute: () => void; isPttMode: boolean; @@ -93,6 +95,8 @@ function usePrefersReducedMotion(): boolean { export function MicControls({ compact = false, + showPttHint = false, + onPttHintDismiss, isMuted, onToggleMute, isPttMode, @@ -146,7 +150,7 @@ export function MicControls({ "overflow-hidden border border-sidebar-border/80 bg-transparent text-sidebar-foreground/70 shadow-none", )} > - + - {isPttMode && !micUnavailable && isEffectivelyMuted ? ( + {showPttHint ? ( + + Hold + + {pushToTalkShortcut} + + to talk — click to switch to open mic + + ) : isPttMode && !micUnavailable && isEffectivelyMuted ? ( Click to unmute or hold diff --git a/desktop/src/features/huddle/lib/audioLink.test.mjs b/desktop/src/features/huddle/lib/audioLink.test.mjs new file mode 100644 index 00000000000..b183a320a3d --- /dev/null +++ b/desktop/src/features/huddle/lib/audioLink.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { audioLinkNotice } from "./audioLink.ts"; + +test("live audio shows no notice", () => { + assert.equal(audioLinkNotice({ status: "live" }), null); + assert.equal(audioLinkNotice(undefined), null); +}); + +test("reconnecting is informational and names a relay restart when draining", () => { + assert.deepEqual( + audioLinkNotice({ status: "reconnecting", attempt: 3, draining: false }), + { tone: "info", message: "Audio dropped. Reconnecting…" }, + ); + assert.deepEqual( + audioLinkNotice({ status: "reconnecting", attempt: 1, draining: true }), + { tone: "info", message: "The huddle relay is restarting. Reconnecting…" }, + ); +}); + +test("a lost link is an error that tells the user what to do", () => { + assert.deepEqual(audioLinkNotice({ status: "lost" }), { + tone: "error", + message: "Couldn’t reconnect audio. Leave and rejoin the huddle.", + }); +}); diff --git a/desktop/src/features/huddle/lib/audioLink.ts b/desktop/src/features/huddle/lib/audioLink.ts new file mode 100644 index 00000000000..d7c1eaf9c87 --- /dev/null +++ b/desktop/src/features/huddle/lib/audioLink.ts @@ -0,0 +1,30 @@ +// Mirrors `AudioLink` in src-tauri/src/huddle/state.rs. +export type HuddleAudioLink = + | { status: "live" } + | { status: "reconnecting"; attempt: number; draining: boolean } + | { status: "lost" }; + +/** + * Banner copy for a degraded audio link, or null while audio is live. Rust + * owns the retry loop; the renderer only reports what it is doing. + */ +export function audioLinkNotice( + link: HuddleAudioLink | undefined, +): { tone: "info" | "error"; message: string } | null { + switch (link?.status) { + case "reconnecting": + return { + tone: "info", + message: link.draining + ? "The huddle relay is restarting. Reconnecting…" + : "Audio dropped. Reconnecting…", + }; + case "lost": + return { + tone: "error", + message: "Couldn’t reconnect audio. Leave and rejoin the huddle.", + }; + default: + return null; + } +} diff --git a/desktop/src/features/huddle/lib/huddleError.test.mjs b/desktop/src/features/huddle/lib/huddleError.test.mjs index ec61b834678..fcea5ea6e7c 100644 --- a/desktop/src/features/huddle/lib/huddleError.test.mjs +++ b/desktop/src/features/huddle/lib/huddleError.test.mjs @@ -44,3 +44,26 @@ test("uses action-specific fallback copy for unknown errors", () => { "Couldn’t start the huddle.", ); }); + +test("maps relay room, drain, owner, and protocol errors to useful copy", () => { + const cases = [ + ["room_full", "This huddle is full."], + ["room_ended", "This huddle has ended."], + ["huddle_relay_draining", "The huddle relay is restarting. Reconnecting…"], + [ + "huddle_owner_unreachable", + "The huddle relay can’t be reached. Try again in a moment.", + ], + ["unsupported_version", "Update Buzz to join this huddle."], + [ + "upgrade_required", + "This huddle uses a newer audio version. Update Buzz, then try again.", + ], + ]; + for (const [code, expected] of cases) { + assert.equal( + formatHuddleActionError(`audio relay error: ${code}`, "join"), + expected, + ); + } +}); diff --git a/desktop/src/features/huddle/lib/huddleError.ts b/desktop/src/features/huddle/lib/huddleError.ts index 7ee47b21337..02b5c258fb2 100644 --- a/desktop/src/features/huddle/lib/huddleError.ts +++ b/desktop/src/features/huddle/lib/huddleError.ts @@ -1,7 +1,40 @@ export type HuddleAction = "join" | "start"; -const HUDDLE_AUDIO_UNAVAILABLE_MESSAGE = - "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; +const HUDDLE_ERROR_MESSAGES: ReadonlyArray<{ + codes: readonly string[]; + message: string; +}> = [ + { + codes: [ + "huddle_audio_unavailable", + "huddle audio unavailable in this deployment", + ], + message: + "Huddle audio isn’t available on this server. Ask an administrator to turn it on.", + }, + { codes: ["room_full"], message: "This huddle is full." }, + { + codes: ["room_ended", "channel is archived"], + message: "This huddle has ended.", + }, + { + codes: ["huddle_relay_draining"], + message: "The huddle relay is restarting. Reconnecting…", + }, + { + codes: ["huddle_owner_unreachable"], + message: "The huddle relay can’t be reached. Try again in a moment.", + }, + { + codes: ["unsupported_version"], + message: "Update Buzz to join this huddle.", + }, + { + codes: ["upgrade_required"], + message: + "This huddle uses a newer audio version. Update Buzz, then try again.", + }, +]; function rawErrorMessage(error: unknown): string | null { if (error instanceof Error) { @@ -20,12 +53,10 @@ export function formatHuddleActionError( const message = rawErrorMessage(error)?.trim(); const normalized = message?.toLowerCase(); - if ( - normalized?.includes("huddle_audio_unavailable") || - normalized?.includes("huddle audio unavailable in this deployment") - ) { - return HUDDLE_AUDIO_UNAVAILABLE_MESSAGE; - } + const mapped = HUDDLE_ERROR_MESSAGES.find(({ codes }) => + codes.some((code) => normalized?.includes(code)), + ); + if (mapped) return mapped.message; if (message) { return message; diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 53399cd2f39..8f06482c041 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -2,6 +2,12 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React from "react"; + +import { useThreadRepliesForRoots } from "./useThreadReplies.ts"; + test("thread replies trust the relay-provided aux closure", async () => { const source = await readFile( new URL("./useThreadReplies.ts", import.meta.url), @@ -13,3 +19,117 @@ test("thread replies trust the relay-provided aux closure", async () => { ); assert.match(source, /replies\.push\(\.\.\.response\.events\)/); }); + +function reply(id, rootId) { + return { + id, + pubkey: "a".repeat(64), + kind: 9, + created_at: 1_700_000_000, + content: "reply", + tags: [["e", rootId, "", "reply"]], + sig: "sig", + }; +} + +async function withHookEnvironment(run) { + const dom = new JSDOM("", { + url: "http://localhost/", + }); + const previousGlobals = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + tauri: globalThis.__TAURI_INTERNALS__, + act: globalThis.IS_REACT_ACT_ENVIRONMENT, + }; + const pendingRequests = new Map(); + const tauriInternals = { + invoke: (command, args) => { + assert.equal(command, "get_thread_replies"); + return new Promise((resolve) => { + pendingRequests.set(args.rootEventId, resolve); + }); + }, + transformCallback: () => 1, + }; + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + __TAURI_INTERNALS__: tauriInternals, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.__TAURI_INTERNALS__ = tauriInternals; + + try { + const { act, renderHook } = await import("@testing-library/react"); + await run({ act, pendingRequests, renderHook }); + } finally { + dom.window.close(); + Object.assign(globalThis, { + window: previousGlobals.window, + document: previousGlobals.document, + __TAURI_INTERNALS__: previousGlobals.tauri, + IS_REACT_ACT_ENVIRONMENT: previousGlobals.act, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: previousGlobals.navigator, + }); + } +} + +function hookWrapper(client) { + return ({ children }) => + React.createElement(QueryClientProvider, { client }, children); +} + +const channel = { id: "huddle", channelType: "stream" }; + +test("window-seeded huddle roots fetch and settle without a visible gap", async () => { + await withHookEnvironment(async ({ act, pendingRequests, renderHook }) => { + const seededRootId = "3".repeat(64); + const seededReply = reply("4".repeat(64), seededRootId); + const authoritativeReply = reply("5".repeat(64), seededRootId); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const placeholderDataByRoot = new Map([[seededRootId, [seededReply]]]); + const view = renderHook( + () => + useThreadRepliesForRoots(channel, [seededRootId], { + placeholderDataByRoot, + }), + { wrapper: hookWrapper(client) }, + ); + + try { + assert.deepEqual(view.result.current.events, [seededReply]); + assert.equal(pendingRequests.has(seededRootId), true); + await act(async () => { + pendingRequests.get(seededRootId)({ + events: [authoritativeReply], + next_cursor: null, + }); + for ( + let attempts = 0; + view.result.current.events[0]?.id !== authoritativeReply.id && + attempts < 50; + attempts += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + assert.deepEqual(view.result.current.events, [authoritativeReply]); + } finally { + view.unmount(); + await client.cancelQueries(); + client.clear(); + client.unmount(); + } + }); +}); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 4d602348f95..aa82f2eb4b9 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -4,7 +4,6 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; - import { threadRepliesKey, sortMessages, @@ -106,17 +105,24 @@ export function combineThreadRepliesResults( * replies into the chat timeline so companion and in-app presentations show the * same conversation without opening a transient thread surface. */ +export type ThreadRepliesForRootsOptions = { + placeholderDataByRoot?: ReadonlyMap; +}; + export function useThreadRepliesForRoots( activeChannel: Channel | null, rootIds: readonly string[], + options: ThreadRepliesForRootsOptions = {}, ) { const queryClient = useQueryClient(); const channelId = activeChannel?.id ?? "none"; + const placeholderDataByRoot = options.placeholderDataByRoot; return useQueries({ queries: rootIds.map((rootId) => ({ queryKey: threadRepliesKey(channelId, rootId), enabled: activeChannel !== null && activeChannel.channelType !== "forum", queryFn: () => loadThreadReplies(queryClient, channelId, rootId), + placeholderData: () => placeholderDataByRoot?.get(rootId), staleTime: 0, gcTime: 60 * 60 * 1_000, })), diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b31504fe0a0..38428ab767c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -23,6 +23,7 @@ import type { UnreadCatchUpChannelResult } from "@/shared/api/tauriUnreadCatchUp import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; +import type { HuddleAudioLink } from "@/features/huddle/lib/audioLink"; import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome"; import { _testRegisterKnownAgents, @@ -3630,6 +3631,7 @@ type MockHuddleState = { transcription_enabled: boolean; is_creator: boolean; voice_input_mode: "push_to_talk" | "voice_activity"; + audio_link: HuddleAudioLink; }; type PersistedMockHuddle = { @@ -3745,6 +3747,7 @@ function initializeMockHuddle( transcription_enabled: seed.transcriptionEnabled ?? false, is_creator: seed.isCreator ?? true, voice_input_mode: "push_to_talk", + audio_link: { status: "live" }, }, }; } @@ -11799,6 +11802,7 @@ export function maybeInstallE2eTauriMocks() { transcription_enabled: false, is_creator: true, voice_input_mode: "push_to_talk", + audio_link: { status: "live" }, }, }; refreshMockHuddleMembership(activeConfig); @@ -11893,6 +11897,7 @@ export function maybeInstallE2eTauriMocks() { transcription_enabled: false, is_creator: false, voice_input_mode: "push_to_talk", + audio_link: { status: "live" }, }); return null; case "set_huddle_transcription_enabled":