You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-api-common/src/impls/messaging.rs

3084 lines
143 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! Unified Message Stream Implementation
//!
//! This module provides a unified implementation for handling real-time messaging
//! that can be used by both gRPC streaming and WebSocket connections.
//!
//! Architecture:
//! - Binary proto encoding/decoding
//! - Shared business logic in impls layer
//! - Transport-agnostic message handling via `MessageSender` and `StreamMessage` traits
//! - Cluster-aware broadcasting (local + Redis)
//! - All logic encapsulated in `StreamMessageHandler` (rate limiting, filtering, permissions)
//! - Complete IO abstraction via `StreamMessage` trait for both sending and receiving
use std::{collections::BTreeMap, sync::Arc};
use synctv_common::ExecutionControl;
use synctv_core::spawn::spawn_monitored;
use synctv_core::{
models::{
ChatMentionInput, ChatMessageType, RoomId, RoomPermission, RoomPermissionSet, RoomStatus,
SendChatMessage, UserId, UserStatus,
},
service::{
ChatService, ContentFilter, OnlinePresenceService, RateLimitConfig,
RequestRateLimiterService, RoomService,
},
};
use synctv_realtime::sync::{ConnectionId, RealtimeEvent, SharedRealtimeEvent};
use crate::chat_event_dispatcher::ChatEventDispatcher;
/// Maximum size of a WebRTC SDP offer/answer payload in bytes.
/// SDP descriptions can be large but should not exceed ~10 KB.
pub const MAX_SDP_SIZE: usize = 10_000;
/// Maximum size of a WebRTC ICE candidate payload in bytes.
/// Individual ICE candidates are small (typically under 200 bytes).
pub const MAX_ICE_CANDIDATE_SIZE: usize = 500;
fn is_private_ice_candidate_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(ip) => {
ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_unspecified()
}
std::net::IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
}
}
}
use crate::impls::playback::PlaybackService;
use crate::impls::playlist_items_snapshot::PlaylistItemsSnapshotService;
use crate::impls::room_members_snapshot::RoomMembersSnapshotService;
use crate::impls::room_settings_snapshot::RoomSettingsSnapshotService;
#[cfg(test)]
use crate::playback_fanout::default_playback_fanout_service;
use crate::playback_fanout::PlaybackFanoutService;
#[cfg(test)]
use crate::resource_change::ResourceInvalidation;
use synctv_proto::client::{ClientMessage, RealtimeTerminationCode, ServerMessage};
use synctv_realtime::fanout::RealtimeEventService;
use synctv_realtime::sync::ConnectionRuntime;
mod resource_observer;
use resource_observer::{ResourceObserver, ResourceObserverParams};
mod concurrency;
pub use concurrency::MessageConcurrencyConfig;
mod heartbeat;
pub use heartbeat::HeartbeatSchedule;
mod observed_playback;
pub use observed_playback::{
spawn_observed_playback_lifecycle_event_source, PlaybackAutoAdvanceSubscriber,
ProviderPlaybackProgressSubscriber,
};
#[cfg(test)]
pub use observed_playback::{ObservedPlaybackLifecycleEvent, ObservedPlaybackLifecycleSubscriber};
mod playback;
mod resource_watch;
pub use resource_watch::{
watch_chat_events_observe, watch_chat_pin_events_observe, watch_playback_observe,
watch_playback_state_observe, watch_playlist_items_observe, watch_room_member_events_observe,
watch_room_settings_observe, ResourceWatchSession, ResourceWatchSessionConfig,
};
mod webrtc;
mod codec;
pub use codec::ProtoCodec;
#[cfg(test)]
pub use codec::{
chat_display_color_from_metadata, chat_display_position_from_metadata,
chat_playback_media_id_from_metadata, chat_playback_metadata_from_metadata,
chat_playback_playlist_id_from_metadata, chat_playback_target_from_metadata,
chat_playback_target_hash, core_chat_attachment_to_proto,
};
pub use codec::{
chat_event_kind_to_proto, chat_message_event_to_proto, chat_message_receive_to_proto,
chat_metadata_for_send, chat_pin_event_to_proto, online_event_to_proto,
room_member_event_to_proto,
};
mod event_policy;
pub use event_policy::{
admin_event_requires_skip_cleanup, disconnect_signal_requires_skip_cleanup,
should_broadcast_user_left,
};
#[cfg(test)]
pub use event_policy::{watch_admin_event_matches, watch_disconnect_signal_matches};
mod identity;
pub use identity::{
guest_display_name, guest_public_id, GuestRealtimeIdentity, RealtimeJoinError,
RealtimePrincipal,
};
mod membership;
#[cfg(test)]
pub use membership::guest_policy_error_to_denial_reason;
use membership::{InitialRealtimeJoinState, RealtimeMembershipAccess, RealtimeMembershipProbe};
/// Cached membership status for heartbeat validation.
///
/// This struct stores the result of a membership check to avoid
/// repeated database queries during heartbeat validation.
#[derive(Clone, Copy, Debug)]
struct CachedMembership {
/// Whether the user is still a valid member of the room
is_member: bool,
}
impl CachedMembership {
/// Create a cached membership from a member lookup result.
fn from_member(member: Option<&synctv_core::models::RoomMember>) -> Self {
match member {
Some(_) => Self { is_member: true },
None => Self { is_member: false },
}
}
}
// Re-use the canonical role proto mapper from client::convert.
use crate::impls::client::convert::chat_metadata_from_proto;
mod transport;
pub use transport::{MessageSender, StreamMessage};
mod media_swarm_tracker;
pub use media_swarm_tracker::{MediaSwarmPeer, MediaSwarmTracker};
mod notifications;
use notifications::{system_notification_server_message, user_notification_server_message};
/// Per-connection stream message handler with complete logic encapsulation
///
/// Each connection gets its own handler instance with:
/// - Connection state (room, typed principal, username)
/// - Message I/O channels
/// - Rate limiting, content filtering, permission checking
/// - Cluster broadcasting
///
/// The handler runs its own message loop, external code only needs to:
/// 1. Create the handler with proper I/O channels
/// 2. Call `start()` to begin processing
pub struct StreamMessageHandler {
room_id: RoomId,
principal: RealtimePrincipal,
username: String,
connection_id: ConnectionId,
room_service: Arc<RoomService>,
/// `ChatService` for chat message handling with business logic.
/// Chat messages are processed through `ChatService::send_message()`
/// which handles permission checks, content filtering, rate limiting, and persistence.
chat_service: Arc<ChatService>,
clock: Arc<dyn synctv_core::Clock>,
event_service: Arc<dyn RealtimeEventService>,
playback_fanout: Arc<dyn PlaybackFanoutService>,
chat_event_dispatcher: Arc<dyn ChatEventDispatcher>,
/// Optional notification service for direct real-time push to connected clients.
/// When set, the handler subscribes to notification events and pushes them
/// without depending on the gRPC notification-to-realtime bridge.
notification_service: Option<Arc<synctv_core::service::UserNotificationService>>,
connection_service: Arc<dyn ConnectionRuntime>,
presence_service: Arc<OnlinePresenceService>,
rate_limiter: Arc<dyn RequestRateLimiterService>,
rate_limit_config: Arc<RateLimitConfig>,
content_filter: Arc<ContentFilter>,
public_id_codec: Arc<synctv_adapter::PublicIdCodec>,
swarm_signing_key: Arc<crate::proxy_signature::MediaSwarmSigningKey>,
media_swarm_tracker: Arc<MediaSwarmTracker>,
runtime_settings_store: Option<Arc<synctv_core::service::RuntimeSettingsStore>>,
sender: Arc<dyn MessageSender>,
playback_service: Arc<dyn PlaybackService>,
playlist_items_snapshot_service: Arc<dyn PlaylistItemsSnapshotService>,
room_members_snapshot_service: Arc<dyn RoomMembersSnapshotService>,
room_settings_snapshot_service: Arc<dyn RoomSettingsSnapshotService>,
resource_observer: Arc<ResourceObserver>,
/// Global per-connection WebSocket message rate limit (messages per second)
ws_message_rate_limit: u32,
/// Tracks whether this connection has an active WebRTC session.
/// Used by `cleanup()` to decrement `WEBRTC_PEERS_ACTIVE` on ungraceful disconnect.
has_voice_rtc_session: Arc<std::sync::atomic::AtomicBool>,
/// Media swarms announced by this connection.
active_media_swarms:
Arc<parking_lot::Mutex<BTreeMap<String, crate::proxy_signature::MediaSwarmTicketClaims>>>,
room_capability_transition_lock: Arc<tokio::sync::Mutex<()>>,
/// When true, `cleanup()` skips broadcasting `UserLeft`.
///
/// Used when:
/// - the event was already published by an explicit API call (`leave_room/delete_room`)
/// - the connection never completed its initial join handshake, so broadcasting
/// `UserLeft` would create a ghost offline event for a user that was never
/// actually announced as online
skip_cleanup_user_left: Arc<std::sync::atomic::AtomicBool>,
/// Arbitrates terminal messages emitted by concurrent per-connection tasks.
realtime_termination_sent: Arc<std::sync::atomic::AtomicBool>,
/// Last known room role for this connection's actor.
///
/// Cleanup uses this cached value so disconnect paths do not depend on a
/// fresh database read while the transport is already failing.
current_room_role: Arc<std::sync::atomic::AtomicI32>,
/// Cached membership status for heartbeat validation.
/// Uses TTL-based expiration (30 seconds) to reduce database load while
/// maintaining reasonable responsiveness to membership changes.
/// Key: (`room_id`, `user_id`) tuple for O(1) lookup.
membership_cache: Arc<moka::sync::Cache<(RoomId, UserId), CachedMembership>>,
/// Room event receiver created during `pre_join()` so transports do not expose
/// a window where the connection is joined in `ConnectionManager` but not yet
/// subscribed in `RoomMessageHub`.
pending_room_event_rx:
Arc<tokio::sync::Mutex<Option<tokio::sync::mpsc::Receiver<SharedRealtimeEvent>>>>,
/// Authenticated member/settings snapshot validated during `pre_join()`.
pending_initial_join_state: Arc<tokio::sync::Mutex<Option<InitialRealtimeJoinState>>>,
/// Instance-level concurrency configuration for backpressure control.
/// This replaces the global `MESSAGE_PROCESSING_SEMAPHORE` with per-AppState configuration.
concurrency_config: Arc<MessageConcurrencyConfig>,
heartbeat_schedule: HeartbeatSchedule,
filter_private_ice_candidates: bool,
}
pub struct StreamMessageHandlerConfig {
pub room_id: RoomId,
pub principal: RealtimePrincipal,
pub connection_id: Option<String>,
pub room_service: Arc<RoomService>,
pub chat_service: Arc<ChatService>,
pub event_service: Arc<dyn RealtimeEventService>,
pub connection_service: Arc<dyn ConnectionRuntime>,
pub rate_limiter: Arc<dyn RequestRateLimiterService>,
pub rate_limit_config: Arc<RateLimitConfig>,
pub content_filter: Arc<ContentFilter>,
pub public_id_codec: Arc<synctv_adapter::PublicIdCodec>,
pub sender: Arc<dyn MessageSender>,
pub concurrency_config: Arc<MessageConcurrencyConfig>,
}
#[derive(Clone)]
pub struct StreamMessageHandlerRuntime {
pub clock: Arc<dyn synctv_core::Clock>,
pub playback_service: Arc<dyn PlaybackService>,
pub playlist_items_snapshot_service: Arc<dyn PlaylistItemsSnapshotService>,
pub room_members_snapshot_service: Arc<dyn RoomMembersSnapshotService>,
pub room_settings_snapshot_service: Arc<dyn RoomSettingsSnapshotService>,
pub playback_fanout: Arc<dyn PlaybackFanoutService>,
pub chat_event_dispatcher: Arc<dyn ChatEventDispatcher>,
pub presence_service: Arc<OnlinePresenceService>,
pub notification_service: Option<Arc<synctv_core::service::UserNotificationService>>,
pub ws_message_rate_limit: u32,
pub heartbeat_schedule: HeartbeatSchedule,
pub filter_private_ice_candidates: bool,
pub swarm_signing_key: Arc<crate::proxy_signature::MediaSwarmSigningKey>,
pub media_swarm_tracker: Arc<MediaSwarmTracker>,
pub runtime_settings_store: Option<Arc<synctv_core::service::RuntimeSettingsStore>>,
}
impl StreamMessageHandlerRuntime {
#[must_use]
#[cfg(test)]
pub fn local(event_service: &Arc<dyn RealtimeEventService>) -> Self {
Self {
clock: Arc::new(synctv_core::SystemClock),
playback_service: Arc::new(tests::UnconfiguredPlaybackService),
playlist_items_snapshot_service: Arc::new(
tests::UnconfiguredPlaylistItemsSnapshotService,
),
room_members_snapshot_service: Arc::new(tests::UnconfiguredRoomMembersSnapshotService),
room_settings_snapshot_service: Arc::new(
tests::UnconfiguredRoomSettingsSnapshotService,
),
playback_fanout: default_playback_fanout_service(
crate::realtime_fanout::local_realtime_fanout_service(event_service.clone()),
),
chat_event_dispatcher: crate::chat_event_dispatcher::default_chat_event_dispatcher(
event_service.clone(),
),
presence_service: Arc::new(OnlinePresenceService::local()),
notification_service: None,
ws_message_rate_limit: 50,
heartbeat_schedule: HeartbeatSchedule::production(),
filter_private_ice_candidates: false,
swarm_signing_key: Arc::new(
crate::proxy_signature::MediaSwarmSigningKey::try_derive_from(
b"test-swarm-signing-key-with-at-least-32-bytes",
)
.expect("test swarm signing key"),
),
media_swarm_tracker: Arc::new(MediaSwarmTracker::new(None, "test:")),
runtime_settings_store: None,
}
}
}
impl Clone for StreamMessageHandler {
fn clone(&self) -> Self {
Self {
room_id: self.room_id,
principal: self.principal.clone(),
username: self.username.clone(),
connection_id: self.connection_id.clone(),
room_service: Arc::clone(&self.room_service),
chat_service: Arc::clone(&self.chat_service),
clock: Arc::clone(&self.clock),
event_service: Arc::clone(&self.event_service),
playback_fanout: Arc::clone(&self.playback_fanout),
chat_event_dispatcher: Arc::clone(&self.chat_event_dispatcher),
notification_service: self.notification_service.clone(),
connection_service: Arc::clone(&self.connection_service),
presence_service: Arc::clone(&self.presence_service),
rate_limiter: Arc::clone(&self.rate_limiter),
rate_limit_config: Arc::clone(&self.rate_limit_config),
content_filter: Arc::clone(&self.content_filter),
public_id_codec: Arc::clone(&self.public_id_codec),
swarm_signing_key: Arc::clone(&self.swarm_signing_key),
media_swarm_tracker: Arc::clone(&self.media_swarm_tracker),
runtime_settings_store: self.runtime_settings_store.clone(),
sender: Arc::clone(&self.sender),
playback_service: self.playback_service.clone(),
playlist_items_snapshot_service: self.playlist_items_snapshot_service.clone(),
room_members_snapshot_service: self.room_members_snapshot_service.clone(),
room_settings_snapshot_service: Arc::clone(&self.room_settings_snapshot_service),
resource_observer: Arc::clone(&self.resource_observer),
ws_message_rate_limit: self.ws_message_rate_limit,
has_voice_rtc_session: Arc::clone(&self.has_voice_rtc_session),
active_media_swarms: Arc::clone(&self.active_media_swarms),
room_capability_transition_lock: Arc::clone(&self.room_capability_transition_lock),
skip_cleanup_user_left: Arc::clone(&self.skip_cleanup_user_left),
realtime_termination_sent: Arc::clone(&self.realtime_termination_sent),
current_room_role: Arc::clone(&self.current_room_role),
membership_cache: Arc::clone(&self.membership_cache),
pending_room_event_rx: Arc::clone(&self.pending_room_event_rx),
pending_initial_join_state: Arc::clone(&self.pending_initial_join_state),
concurrency_config: Arc::clone(&self.concurrency_config),
heartbeat_schedule: self.heartbeat_schedule,
filter_private_ice_candidates: self.filter_private_ice_candidates,
}
}
}
fn guest_access_revocation_reason<'a>(
principal: &RealtimePrincipal,
event: &'a RealtimeEvent,
) -> Option<&'a str> {
if !principal.is_guest() {
return None;
}
match event {
RealtimeEvent::GuestAccessRevoked { reason, .. } => Some(reason),
_ => None,
}
}
impl StreamMessageHandler {
#[must_use]
pub fn generate_connection_id() -> ConnectionId {
ConnectionId::new(format!("conn_c{}", synctv_common::snanoid!(16)))
}
fn error_server_message(error: impl Into<crate::impls::ApiError>) -> ServerMessage {
Self::operation_error_server_message(error, None)
}
fn operation_error_server_message(
error: impl Into<crate::impls::ApiError>,
client_operation_id: Option<&str>,
) -> ServerMessage {
let api_error: crate::impls::ApiError = error.into();
let mut error = api_error.to_proto_error();
error.client_operation_id = client_operation_id.unwrap_or_default().to_string();
ServerMessage {
message: Some(synctv_proto::client::server_message::Message::Error(error)),
}
}
fn send_realtime_termination<S: StreamMessage>(
&self,
stream: &S,
message: impl Into<String>,
code: RealtimeTerminationCode,
) {
self.send_realtime_termination_message(
stream,
realtime_termination_server_message(message, code),
);
}
fn send_realtime_termination_message<S: StreamMessage>(
&self,
stream: &S,
message: ServerMessage,
) {
if !self.should_send_server_message(&message) {
return;
}
if let Err(error) = stream.send(message) {
tracing::debug!(
actor = %self.username,
room_id = %self.room_id,
error = %error,
"Failed to send realtime termination before closing stream"
);
}
}
fn send_server_message(&self, message: ServerMessage) -> Result<(), String> {
if self.should_send_server_message(&message) {
self.sender.send(message)
} else {
Ok(())
}
}
fn should_send_server_message(&self, message: &ServerMessage) -> bool {
use synctv_proto::client::server_message::Message;
if !matches!(message.message.as_ref(), Some(Message::Termination(_))) {
return true;
}
self.realtime_termination_sent
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::AcqRel,
std::sync::atomic::Ordering::Acquire,
)
.is_ok()
}
fn client_operation_id(message: &ClientMessage) -> Option<&str> {
use synctv_proto::client::client_message::Message;
match message.message.as_ref() {
Some(Message::PlaybackStateUpdate(update)) => update.client_operation_id.as_deref(),
Some(Message::PlaybackUpdate(update)) => update.client_operation_id.as_deref(),
Some(Message::Webrtc(command)) => command.command.as_ref().and_then(|command| {
if let synctv_proto::client::web_rtc_command::Command::VoiceJoin(join) = command {
join.client_operation_id.as_deref()
} else {
None
}
}),
_ => None,
}
}
fn membership_probe(&self) -> RealtimeMembershipProbe<'_> {
RealtimeMembershipProbe::new(&self.room_service)
}
#[cfg(test)]
pub fn new(config: StreamMessageHandlerConfig) -> Self {
let runtime = StreamMessageHandlerRuntime::local(&config.event_service);
Self::new_with_runtime(config, runtime)
}
pub fn new_with_runtime(
config: StreamMessageHandlerConfig,
runtime: StreamMessageHandlerRuntime,
) -> Self {
let StreamMessageHandlerConfig {
room_id,
principal,
connection_id,
room_service,
chat_service,
event_service,
connection_service,
rate_limiter,
rate_limit_config,
content_filter,
public_id_codec,
sender,
concurrency_config,
} = config;
let connection_id =
connection_id.map_or_else(Self::generate_connection_id, ConnectionId::new);
let username = principal.username().to_string();
let heartbeat_schedule = runtime.heartbeat_schedule;
let membership_cache = Arc::new(
moka::sync::Cache::builder()
.time_to_live(heartbeat_schedule.membership_cache_ttl())
.build(),
);
let room_settings_snapshot_service = Arc::clone(&runtime.room_settings_snapshot_service);
let room_actor = principal.room_actor(room_id);
let chat_event_dispatcher = runtime.chat_event_dispatcher;
let playback_fanout = runtime.playback_fanout;
let presence_service = runtime.presence_service;
let clock = runtime.clock;
let resource_observer = Arc::new(ResourceObserver::new(ResourceObserverParams {
room_id,
actor: room_actor,
connection_id: connection_id.as_str().to_string(),
room_service: Arc::clone(&room_service),
chat_service: Some(Arc::clone(&chat_service)),
clock: Arc::clone(&clock),
presence_service: Arc::clone(&presence_service),
public_id_codec: Arc::clone(&public_id_codec),
sender: Arc::clone(&sender),
playback_service: Arc::clone(&runtime.playback_service),
playlist_items_snapshot_service: Arc::clone(&runtime.playlist_items_snapshot_service),
room_settings_snapshot_service: Arc::clone(&room_settings_snapshot_service),
}));
Self {
room_id,
principal,
username,
connection_id,
room_service,
chat_service,
clock,
event_service,
playback_fanout,
chat_event_dispatcher,
notification_service: runtime.notification_service,
connection_service,
presence_service,
rate_limiter,
rate_limit_config,
content_filter,
public_id_codec,
swarm_signing_key: runtime.swarm_signing_key,
media_swarm_tracker: runtime.media_swarm_tracker,
runtime_settings_store: runtime.runtime_settings_store,
sender,
playback_service: runtime.playback_service,
playlist_items_snapshot_service: runtime.playlist_items_snapshot_service,
room_members_snapshot_service: runtime.room_members_snapshot_service,
room_settings_snapshot_service,
resource_observer,
ws_message_rate_limit: runtime.ws_message_rate_limit,
has_voice_rtc_session: Arc::new(std::sync::atomic::AtomicBool::new(false)),
active_media_swarms: Arc::new(parking_lot::Mutex::new(BTreeMap::new())),
room_capability_transition_lock: Arc::new(tokio::sync::Mutex::new(())),
skip_cleanup_user_left: Arc::new(std::sync::atomic::AtomicBool::new(false)),
realtime_termination_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)),
current_room_role: Arc::new(std::sync::atomic::AtomicI32::new(
synctv_proto::common::RoomMemberRole::Member as i32,
)),
membership_cache,
pending_room_event_rx: Arc::new(tokio::sync::Mutex::new(None)),
pending_initial_join_state: Arc::new(tokio::sync::Mutex::new(None)),
concurrency_config,
heartbeat_schedule,
filter_private_ice_candidates: runtime.filter_private_ice_candidates,
}
}
#[must_use]
pub fn connection_id(&self) -> &str {
self.connection_id.as_str()
}
fn public_room_id(&self) -> Result<String, String> {
self.public_id_codec
.encode_room_id(self.room_id)
.map_err(|error| format!("Failed to encode room public id: {error}"))
}
fn public_actor_id(&self) -> Result<String, String> {
self.principal.public_actor_id(&self.public_id_codec)
}
fn realtime_actor(&self) -> Result<synctv_core::models::RealtimeActor, String> {
self.principal.realtime_actor(&self.public_id_codec)
}
fn require_user_id(&self) -> Result<UserId, String> {
self.principal
.user_id()
.ok_or_else(|| "This realtime operation requires a signed-in user".to_string())
}
fn webrtc_event_server_message_for_current_connection(
&self,
event: &RealtimeEvent,
) -> Result<Option<ServerMessage>, String> {
use synctv_proto::client::resource_event::Payload;
use synctv_proto::client::server_message::Message;
use synctv_proto::client::web_rtc_event::Event;
use synctv_proto::client::{
ResourceEvent, ServerMessage, WebRtcEvent, WebRtcMediaAnswer, WebRtcMediaIceCandidate,
WebRtcMediaOffer, WebRtcMediaPeerLeft, WebRtcVoiceAnswer, WebRtcVoiceIceCandidate,
WebRtcVoiceOffer, WebRtcVoicePeerJoined, WebRtcVoicePeerLeft,
};
use synctv_realtime::sync::WebRTCSignalKind;
let payload = match event {
RealtimeEvent::WebRTCVoiceSignaling {
message_type,
from,
to,
data,
..
} => {
let Some((actor_id, conn_id)) = to.rsplit_once(':') else {
return Ok(None);
};
if conn_id != self.connection_id.as_str() || actor_id != self.public_actor_id()? {
return Ok(None);
}
match message_type {
WebRTCSignalKind::Offer => Event::VoiceOffer(WebRtcVoiceOffer {
from: from.clone(),
data: data.clone(),
}),
WebRTCSignalKind::Answer => Event::VoiceAnswer(WebRtcVoiceAnswer {
from: from.clone(),
data: data.clone(),
}),
WebRTCSignalKind::IceCandidate => {
Event::VoiceIceCandidate(WebRtcVoiceIceCandidate {
from: from.clone(),
data: data.clone(),
})
}
}
}
RealtimeEvent::WebRTCMediaSignaling {
message_type,
from,
to,
data,
swarm_id,
..
} => {
let Some((actor_id, conn_id)) = to.rsplit_once(':') else {
return Ok(None);
};
if conn_id != self.connection_id.as_str() || actor_id != self.public_actor_id()? {
return Ok(None);
}
match message_type {
WebRTCSignalKind::Offer => Event::MediaOffer(WebRtcMediaOffer {
from: from.clone(),
data: data.clone(),
swarm_id: swarm_id.clone(),
}),
WebRTCSignalKind::Answer => Event::MediaAnswer(WebRtcMediaAnswer {
from: from.clone(),
data: data.clone(),
swarm_id: swarm_id.clone(),
}),
WebRTCSignalKind::IceCandidate => {
Event::MediaIceCandidate(WebRtcMediaIceCandidate {
from: from.clone(),
data: data.clone(),
swarm_id: swarm_id.clone(),
})
}
}
}
RealtimeEvent::WebRTCVoicePeerJoined {
actor_id,
conn_id,
username,
..
} => {
if conn_id == self.connection_id.as_str() || !self.current_connection_voice_joined()
{
return Ok(None);
}
Event::VoicePeerJoined(WebRtcVoicePeerJoined {
user_id: actor_id.clone(),
conn_id: conn_id.clone(),
username: username.clone(),
})
}
RealtimeEvent::WebRTCVoicePeerLeft {
actor_id, conn_id, ..
} => {
if conn_id == self.connection_id.as_str() || !self.current_connection_voice_joined()
{
return Ok(None);
}
Event::VoicePeerLeft(WebRtcVoicePeerLeft {
user_id: actor_id.clone(),
conn_id: conn_id.clone(),
})
}
RealtimeEvent::MediaSwarmPeerLeft {
actor_id,
conn_id,
swarm_id,
..
} => {
if conn_id == self.connection_id.as_str()
|| !self.active_media_swarms.lock().contains_key(swarm_id)
{
return Ok(None);
}
Event::MediaPeerLeft(WebRtcMediaPeerLeft {
swarm_id: swarm_id.clone(),
user_id: actor_id.clone(),
conn_id: conn_id.clone(),
})
}
_ => return Ok(None),
};
Ok(Some(ServerMessage {
message: Some(Message::ResourceEvent(ResourceEvent {
observe_id: "webrtc".to_string(),
payload: Some(Payload::WebrtcEvent(WebRtcEvent {
event: Some(payload),
})),
event_cursor: None,
})),
}))
}
fn current_connection_voice_joined(&self) -> bool {
self.connection_service
.get_connection(self.connection_id.as_str())
.is_some_and(|connection| connection.voice_rtc_joined)
}
fn apply_connection_state_from_room_event(&self, event: &RealtimeEvent) {
if let RealtimeEvent::PermissionChanged {
target_user_id,
role_changed,
role,
..
} = event
{
if self.principal.user_id() == Some(*target_user_id) && *role_changed {
self.current_room_role
.store(i32::from(*role), std::sync::atomic::Ordering::Relaxed);
}
}
}
async fn guest_permissions(&self) -> Result<RoomPermissionSet, synctv_core::Error> {
self.room_service.get_guest_permissions(&self.room_id).await
}
async fn check_realtime_permission(
&self,
permission: RoomPermission,
) -> Result<(), synctv_core::Error> {
if let Some(user_id) = self.principal.user_id() {
self.room_service
.check_permission(&self.room_id, &user_id, permission)
.await
} else {
let permissions = self.guest_permissions().await?;
if permissions.has(permission) {
Ok(())
} else {
Err(synctv_core::Error::Authorization(
"Guests do not have permission to perform this action".to_string(),
))
}
}
}
async fn ensure_observe_resource_allowed(
&self,
observe: &synctv_proto::client::ObserveResource,
) -> Result<(), String> {
let Some(resource) = observe.resource.as_ref() else {
return Err("observe resource is required".to_string());
};
match resource {
synctv_proto::client::observe_resource::Resource::PlaybackState(_)
| synctv_proto::client::observe_resource::Resource::RoomSettings(_)
| synctv_proto::client::observe_resource::Resource::OnlineCount(_)
| synctv_proto::client::observe_resource::Resource::SelfRoomMember(_) => {
if self.principal.is_guest() {
self.ensure_guest_admission_for_action().await?;
}
Ok(())
}
synctv_proto::client::observe_resource::Resource::PlaylistItems(_) => {
if self.principal.is_guest() {
Err("Guests cannot observe playlist items".to_string())
} else {
Ok(())
}
}
synctv_proto::client::observe_resource::Resource::PlaybackHistory(_) => {
if self.principal.is_guest() {
return Err("Guests cannot observe playback history".to_string());
}
self.check_realtime_permission(RoomPermission::VIEW_PLAYBACK_HISTORY)
.await
.map_err(|error| error.to_string())
}
synctv_proto::client::observe_resource::Resource::RoomMemberEvents(_)
| synctv_proto::client::observe_resource::Resource::OnlineEvent(_) => {
if self.principal.is_guest() {
self.ensure_guest_admission_for_action().await?;
}
self.check_realtime_permission(RoomPermission::VIEW_MEMBERS)
.await
.map_err(|e| e.to_string())
}
synctv_proto::client::observe_resource::Resource::ChatEvents(_)
| synctv_proto::client::observe_resource::Resource::ChatPinEvents(_) => {
if self.principal.is_guest() {
self.ensure_guest_admission_for_action().await?;
}
self.check_realtime_permission(RoomPermission::VIEW_CHAT_HISTORY)
.await
.map_err(|e| e.to_string())
}
synctv_proto::client::observe_resource::Resource::Playback(_) => {
if self.principal.is_guest() {
self.ensure_guest_admission_for_action().await?;
Ok(())
} else {
Ok(())
}
}
}
}
async fn guest_admission_denial_reason(&self) -> Result<Option<String>, RealtimeJoinError> {
self.membership_probe()
.guest_admission_denial_reason(&self.room_id, &self.principal)
.await
}
async fn realtime_access_denial_reason(&self) -> Result<Option<String>, RealtimeJoinError> {
match self.principal.user_id() {
Some(user_id) => self
.membership_probe()
.realtime_membership_denial_reason(&self.room_id, &user_id)
.await
.map_err(|error| RealtimeJoinError::from(error.to_string())),
None => self.guest_admission_denial_reason().await,
}
}
async fn prepare_initial_realtime_join_state(
&self,
) -> Result<Result<InitialRealtimeJoinState, String>, RealtimeJoinError> {
if self.principal.is_guest() {
return Ok(match self.guest_admission_denial_reason().await? {
Some(reason) => Err(reason),
None => Ok(InitialRealtimeJoinState {
member: None,
room_settings: None,
}),
});
}
let user_id = self
.require_user_id()
.map_err(RealtimeJoinError::PermissionDenied)?;
let user = self
.room_service
.user_service()
.get_user(&user_id)
.await
.map_err(|error| {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to re-validate user access during pre_join; rejecting connection because final admission must fail closed"
);
RealtimeJoinError::ServiceUnavailable(
"User re-validation temporarily unavailable".to_string(),
)
})?;
if user.status == UserStatus::Banned {
return Ok(Err(
"User is no longer allowed to use real-time messaging".to_string()
));
}
if user.deleted_at.is_some() {
return Ok(Err("User account is no longer available".to_string()));
}
let room = self.room_service.get_room(&self.room_id).await.map_err(|error| {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to re-validate room access during pre_join; rejecting connection because final admission must fail closed"
);
RealtimeJoinError::ServiceUnavailable(
"Room re-validation temporarily unavailable".to_string(),
)
})?;
if room.is_banned {
return Ok(Err("This room has been banned".to_string()));
}
if room.status == RoomStatus::Closed {
return Ok(Err(
"This room is closed and not accepting new connections".to_string()
));
}
let membership_lookup = self
.membership_probe()
.probe_realtime_membership_access_with_room(&room, &user_id)
.await;
let member = match membership_lookup {
Ok(RealtimeMembershipAccess::Allowed(member)) => member,
Ok(RealtimeMembershipAccess::Denied(reason))
| Err(synctv_core::Error::Authorization(reason)) => return Ok(Err(reason)),
Err(error) => {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to re-validate membership during pre_join; rejecting connection because final admission must fail closed"
);
return Err(RealtimeJoinError::ServiceUnavailable(
"Membership re-validation temporarily unavailable".to_string(),
));
}
};
let room_settings = self.room_service.get_room_settings(&self.room_id).await.map_err(
|error| {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to load room settings during pre_join; rejecting connection because permission snapshots must fail closed"
);
RealtimeJoinError::from(crate::impls::ApiError::from(error))
},
)?;
Ok(Ok(InitialRealtimeJoinState {
member: Some(member),
room_settings: Some(room_settings),
}))
}
/// Register the connection and join the room, enforcing connection limits.
///
/// Call this **before** returning the gRPC response stream so that limit
/// violations surface as a proper gRPC error instead of silently failing
/// inside a background task. After a successful `pre_join`, call
/// [`run_after_join`] to enter the message loop.
pub async fn pre_join(&self) -> Result<(), RealtimeJoinError> {
let actor = self.realtime_actor().map_err(RealtimeJoinError::from)?;
if let Err(e) = self
.connection_service
.register_actor(self.connection_id.clone().into_string(), actor)
.await
{
tracing::warn!("Failed to register connection: {}", e);
return Err(RealtimeJoinError::from(
crate::runtime::RealtimeAdmissionError::from_runtime_message(e),
));
}
self.pre_join_after_registration().await
}
/// Continue admission after the connection was already registered.
///
/// This is used by transports that need an early registration/backpressure
/// step before they can finish reading the room-scoped handshake.
pub async fn pre_join_after_registration(&self) -> Result<(), RealtimeJoinError> {
if let Err(e) = self
.connection_service
.join_room(self.connection_id.as_str(), self.room_id)
.await
{
self.connection_service
.unregister(self.connection_id.as_str())
.await;
return Err(RealtimeJoinError::from(
crate::runtime::RealtimeAdmissionError::from_runtime_message(e),
));
}
let initial_join_state = match self.prepare_initial_realtime_join_state().await {
Ok(state) => state,
Err(error) => {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.connection_service
.unregister(self.connection_id.as_str())
.await;
return Err(error);
}
};
let initial_join_state = match initial_join_state {
Ok(state) => state,
Err(reason) => {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.connection_service
.unregister(self.connection_id.as_str())
.await;
return Err(RealtimeJoinError::PermissionDenied(reason));
}
};
if let Err(error) = self
.cache_initial_realtime_join_state(initial_join_state)
.await
{
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.connection_service
.unregister(self.connection_id.as_str())
.await;
return Err(error);
}
if let Err(error) = self.cache_room_event_subscription().await {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.connection_service
.unregister(self.connection_id.as_str())
.await;
return Err(error);
}
Ok(())
}
async fn cache_initial_realtime_join_state(
&self,
state: InitialRealtimeJoinState,
) -> Result<(), RealtimeJoinError> {
let mut pending_state = self.pending_initial_join_state.lock().await;
if pending_state.is_some() {
return Err(RealtimeJoinError::Internal(
"Initial realtime join state is already cached".to_string(),
));
}
*pending_state = Some(state);
Ok(())
}
async fn cache_room_event_subscription(&self) -> Result<(), RealtimeJoinError> {
let mut pending_rx = self.pending_room_event_rx.lock().await;
if pending_rx.is_some() {
return Ok(());
}
let actor = self.realtime_actor().map_err(RealtimeJoinError::from)?;
let (event_rx, _connection_id) = self
.event_service
.subscribe_with_id(self.room_id, actor, self.connection_id.clone())
.await
.map_err(|e| {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
RealtimeJoinError::Internal(format!(
"Failed to subscribe to realtime events during pre_join: {e}"
))
})?;
*pending_rx = Some(event_rx);
Ok(())
}
async fn take_room_event_subscription(
&self,
) -> Result<tokio::sync::mpsc::Receiver<SharedRealtimeEvent>, String> {
if let Some(event_rx) = self.pending_room_event_rx.lock().await.take() {
return Ok(event_rx);
}
let actor = self.realtime_actor()?;
self.event_service
.subscribe_with_id(self.room_id, actor, self.connection_id.clone())
.await
.map(|(event_rx, _connection_id)| event_rx)
.map_err(|e| format!("Failed to subscribe to realtime events: {e}"))
}
/// Run the complete message loop using unified IO abstraction.
///
/// This is the recommended method that handles both sending and receiving
/// in a single unified loop using the `StreamMessage` trait.
///
/// This method:
/// 1. Registers the connection and joins the room (enforcing limits)
/// 2. Subscribes to realtime events and forwards them to the client
/// 3. Receives client messages via the `StreamMessage` trait
/// 4. Handles rate limiting, content filtering, and permissions
/// 5. Broadcasts events to the cluster
/// 6. Monitors for disconnect signals (user ban, kick, etc.)
/// 7. Handles cleanup on disconnect
///
/// The caller only needs to provide a `StreamMessage` implementation (WebSocket or gRPC).
///
/// If you need to check connection limits *before* returning a response stream
/// (e.g. in gRPC), call [`pre_join`] first and then [`run_after_join`].
pub async fn run<S: StreamMessage>(&self, stream: &mut S) -> Result<(), String> {
self.pre_join().await.map_err(String::from)?;
self.run_after_join(stream).await
}
/// Continue the message loop after a successful [`pre_join`].
///
/// This is identical to [`run`] but skips the register/join_room steps
/// that were already performed by `pre_join`.
pub async fn run_after_join<S: StreamMessage>(&self, stream: &mut S) -> Result<(), String> {
let room_id_str = self.public_room_id()?;
// Pre-join caches the room subscription so there is no gap between
// admission success and the transport starting its receive loop.
let mut event_rx = self.take_room_event_subscription().await?;
// Subscribe to disconnect signals
let mut disconnect_rx = self.connection_service.subscribe_disconnect();
// Subscribe to admin events (KickUser, etc.) for cross-replica disconnect propagation.
// KickUser events arrive via Redis PubSub on the admin channel and are not
// delivered through the room-level event subscription, so each connection
// must independently monitor admin events and disconnect when targeted.
let mut admin_rx = self.event_service.subscribe_admin_events();
// Subscribe to notification events directly so WebSocket clients receive
// notifications even when the gRPC notification bridge is not running.
let mut notification_rx = self
.notification_service
.as_ref()
.map(|svc| svc.subscribe_events());
// Fetch member data and room settings once and reuse them for the join
// payload and realtime event. Authenticated users must have both so
// outbound permission snapshots cannot silently fall back to role-only
// defaults when a read fails.
let initial_join = self.take_initial_realtime_join_state(&room_id_str).await?;
if let Some(member) = initial_join.member.as_ref() {
self.current_room_role
.store(i32::from(member.role), std::sync::atomic::Ordering::Relaxed);
}
// Broadcast UserJoined event to observers and other replicas.
self.broadcast_user_joined(
initial_join.member.as_ref(),
initial_join.room_settings.as_ref(),
)
.await;
// Create heartbeat interval OUTSIDE the loop so it doesn't reset
// when other select! branches fire.
// Add random jitter (±5 s around the 30 s base) so that 1000 concurrent
// connections do not all fire their DB membership checks in the same
// one-second window (thundering-herd protection).
let heartbeat_period = self.heartbeat_schedule.period_with_random_jitter();
let mut heartbeat_interval = tokio::time::interval(heartbeat_period);
heartbeat_interval.tick().await; // Skip the immediate first tick
// Global per-connection message rate limiter (token bucket).
// Configured via connection_limits.ws_message_rate_limit_per_second.
// This is local to each connection (no Redis needed).
let global_msg_rate_limit = self.ws_message_rate_limit;
let mut global_msg_count: u32 = 0;
let mut global_msg_window_start = tokio::time::Instant::now();
let message_control = ExecutionControl::default();
// Main message loop using tokio::select! for concurrent operations
loop {
tokio::select! {
// Incoming client message
client_msg_result = stream.recv() => {
match client_msg_result {
Some(Ok(msg)) => {
self.connection_service.record_message(self.connection_id.as_str());
// Global per-connection rate limit check (before any processing)
let now = tokio::time::Instant::now();
if now.duration_since(global_msg_window_start) >= std::time::Duration::from_secs(1) {
// Reset window
global_msg_count = 0;
global_msg_window_start = now;
}
global_msg_count += 1;
if global_msg_count > global_msg_rate_limit {
tracing::warn!(
actor = %self.username,
room_id = %self.room_id,
limit = global_msg_rate_limit,
"Global WebSocket message rate limit exceeded, dropping message"
);
continue;
}
// Backpressure control: try to acquire a semaphore permit.
// If the system is overloaded, return ResourceExhausted error instead of processing.
let semaphore = self.concurrency_config.semaphore();
let Ok(permit) = semaphore.try_acquire_owned() else {
tracing::warn!(
actor = %self.username,
room_id = %self.room_id,
"System overloaded: message processing semaphore exhausted, returning ResourceExhausted"
);
// Send ResourceExhausted error to client
let error_msg = Self::error_server_message(
crate::impls::ApiError::RateLimited(
"System overloaded, please retry later".to_string(),
),
);
if let Err(e) = stream.send(error_msg) {
tracing::error!(
"Failed to send ResourceExhausted error to client: {}",
e
);
break;
}
continue;
};
// Process message with semaphore permit held
let _permit = permit; // Hold permit for duration of processing
if let Err(e) = self
.handle_client_message_with_control(&msg, Some(&message_control))
.await
{
tracing::error!("Failed to handle client message: {}", e);
if let Err(send_err) =
stream.send(Self::operation_error_server_message(
e.clone(),
Self::client_operation_id(&msg),
))
{
tracing::error!(
"Failed to send message error to client: {}",
send_err
);
break;
}
}
}
Some(Err(e)) => {
tracing::error!("Error receiving message: {}", e);
break;
}
None => {
tracing::info!("Client disconnected gracefully");
break;
}
}
}
// Realtime event (broadcast to client)
event = event_rx.recv() => {
if let Some(event) = event {
if let Some(reason) =
guest_access_revocation_reason(&self.principal, &event)
{
tracing::info!(
room_id = %self.room_id,
actor = %self.username,
reason,
"Guest access was revoked, disconnecting"
);
self.send_realtime_termination(
stream,
"Guest access to this room has ended",
RealtimeTerminationCode::GuestAccessRevoked,
);
break;
}
self.apply_connection_state_from_room_event(&event);
self.apply_rtc_access_change(&event).await;
match self.webrtc_event_server_message_for_current_connection(&event) {
Ok(Some(message)) => {
if let Err(error) = stream.send(message) {
tracing::error!("Failed to send WebRTC server message: {}", error);
break;
}
}
Ok(None) => {}
Err(error) => {
tracing::error!(
room_id = %self.room_id,
event_id = %event.event_id(),
error = %error,
"Failed to convert WebRTC event to server message"
);
break;
}
}
if !matches!(event.as_ref(), RealtimeEvent::ChatMessageEvent { .. }) {
let mut send_failed = false;
let messages = match realtime_event_to_server_messages(
&event,
&room_id_str,
&self.public_id_codec,
) {
Ok(messages) => messages,
Err(error) => {
tracing::error!(
room_id = %self.room_id,
event_id = %event.event_id(),
error = %error,
"Failed to convert realtime event to server message"
);
break;
}
};
for msg in messages {
if let Err(e) = stream.send(msg) {
tracing::error!("Failed to send server message: {}", e);
send_failed = true;
break;
}
}
if send_failed {
break;
}
}
let should_refresh_observed_resources = !matches!(
event.as_ref(),
RealtimeEvent::ChatMessageEvent { .. }
) || self.resource_observer.has_chat_events_observation().await;
if should_refresh_observed_resources {
if let Err(error) = self
.resource_observer
.room_hub
.refresh_for_room_event(&event, Some(self.connection_id.as_str()))
.await
{
tracing::error!(
"Failed to refresh observed resources for room event: {}",
error
);
break;
}
}
} else {
tracing::error!("Realtime event channel closed");
break;
}
}
() = self.resource_observer.wait_for_expired_resource_refresh_deadline() => {
if let Err(error) = self
.resource_observer
.refresh_expired_resource_observations()
.await
{
tracing::error!(
"Failed to refresh observed resources after expiration: {}",
error
);
break;
}
}
// Disconnect signal (forced disconnect by server)
signal = disconnect_rx.recv() => {
match signal {
Ok(synctv_realtime::sync::DisconnectSignal::Connection(conn_id)) => {
if conn_id == self.connection_id.as_str() {
tracing::info!(
connection_id = %self.connection_id,
"Received disconnect signal for this connection"
);
self.send_realtime_termination(
stream,
"Connection closed by server",
RealtimeTerminationCode::ConnectionRevoked,
);
break;
}
}
Ok(synctv_realtime::sync::DisconnectSignal::User(uid)) => {
if self.principal.user_id() == Some(uid) {
tracing::info!(
actor = %self.username,
"Received disconnect signal for this user (room kick or platform ban)"
);
self.send_realtime_termination(
stream,
"Your account access has been revoked",
RealtimeTerminationCode::UserAccessRevoked,
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Ok(synctv_realtime::sync::DisconnectSignal::Room {
room_id: rid,
reason,
}) => {
if rid == self.room_id {
tracing::info!(
room_id = %self.room_id,
?reason,
"Received disconnect signal for this room"
);
self.send_realtime_termination_message(
stream,
room_disconnect_termination_server_message(reason),
);
// Room deletion already published RoomDeleted;
// skip redundant UserLeft.
self.skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Ok(synctv_realtime::sync::DisconnectSignal::UserFromRoom { user_id: uid, room_id: rid }) => {
if self.principal.user_id() == Some(uid) && rid == self.room_id {
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
"Received disconnect signal: kicked from room"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
// The leave_room API already published UserLeft;
// skip redundant broadcast in cleanup().
self.skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Channel lagged: we may have missed critical disconnect signals.
// Re-subscribe to get a fresh receiver so future signals are not lost,
// then verify membership to catch any missed room kick or platform ban.
tracing::warn!(
lagged = n,
actor = %self.username,
room_id = %self.room_id,
"Disconnect signal channel lagged, re-subscribing and verifying membership"
);
disconnect_rx = self.connection_service.subscribe_disconnect();
match self.realtime_access_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
reason,
"Real-time access is no longer valid (detected after disconnect signal lag), disconnecting"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to verify membership after disconnect signal lag"
);
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::error!("Disconnect signal channel closed");
break;
}
}
}
// Admin events from cluster (cross-replica room kick or platform ban propagation)
admin_event = admin_rx.recv() => {
match admin_event {
Ok(RealtimeEvent::KickUser { ref user_id, ref reason, .. }) => {
// Invalidate membership cache immediately so the disconnected user
// cannot send messages during the remaining cache TTL window.
let cache_key = (self.room_id, *user_id);
self.membership_cache.invalidate(&cache_key);
if self.principal.user_id() == Some(*user_id) {
tracing::info!(
actor = %self.username,
reason = %reason,
"Received cross-replica KickUser event, disconnecting"
);
self.send_realtime_termination(
stream,
"Your account access has been revoked",
RealtimeTerminationCode::UserAccessRevoked,
);
self.skip_cleanup_user_left.store(
true,
std::sync::atomic::Ordering::Relaxed,
);
break;
}
}
Ok(RealtimeEvent::KickUserFromRoom { ref user_id, ref room_id, ref reason, .. }) => {
// Invalidate membership cache immediately so the kicked or platform-banned
// user cannot send messages during the remaining cache TTL window.
let cache_key = (*room_id, *user_id);
self.membership_cache.invalidate(&cache_key);
if self.principal.user_id() == Some(*user_id) && *room_id == self.room_id {
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
reason = %reason,
"Received cross-replica KickUserFromRoom event, disconnecting"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
self.skip_cleanup_user_left.store(
true,
std::sync::atomic::Ordering::Relaxed,
);
break;
}
}
Ok(RealtimeEvent::RoomDeleted { ref room_id, .. }) => {
if *room_id == self.room_id {
self.send_realtime_termination(
stream,
"Room has been deleted",
RealtimeTerminationCode::RoomDeleted,
);
self.skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Ok(RealtimeEvent::RoomBanned { ref room_id, .. }) => {
if *room_id == self.room_id {
self.send_realtime_termination(
stream,
"Room has been banned",
RealtimeTerminationCode::RoomBanned,
);
self.skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Ok(RealtimeEvent::RoomOwnerInactive { ref room_id, .. }) => {
if *room_id == self.room_id {
self.send_realtime_termination(
stream,
"Room is unavailable because its creator is not active",
RealtimeTerminationCode::RoomOwnerInactive,
);
self.skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
Ok(RealtimeEvent::UserNotification { ref user_id, ref title, ref content, ref notification_type, ref data, ref notification_id, timestamp, .. }) => {
// Uses the dedicated Notification variant (not ErrorMessage abuse).
if self.principal.user_id() == Some(*user_id) {
let msg = user_notification_server_message(
notification_id.clone(),
*notification_type,
data,
title.clone(),
content.clone(),
timestamp,
);
if let Err(e) = stream.send(msg) {
tracing::error!("Failed to push notification to WebSocket: {}", e);
break;
}
}
}
Ok(RealtimeEvent::SystemNotification { ref message, timestamp, .. }) => {
let msg = match system_notification_server_message(message.clone(), timestamp) {
Ok(msg) => msg,
Err(error) => {
tracing::error!(error = %error, "Invalid system notification realtime event");
break;
}
};
if let Err(e) = stream.send(msg) {
tracing::error!("Failed to push system notification to WebSocket: {}", e);
break;
}
}
Ok(RealtimeEvent::ProviderCredentialChanged { ref event_id, ref user_id, ref provider, ref server_id, .. }) => {
self.resource_observer.handle_provider_credential_changed_admin_event(
event_id,
user_id,
*provider,
server_id,
)
.await;
}
Ok(RealtimeEvent::CacheInvalidate { ref event_id, ref targets, .. }) => {
self.resource_observer.handle_cache_invalidate_admin_event(event_id, targets).await;
}
Ok(_) => {
// Other admin events (KickPublisher, etc.) not relevant to this connection
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Channel lagged: we may have missed critical KickUser/KickUserFromRoom events.
// Re-subscribe to get a fresh receiver so future events are not lost.
tracing::warn!(
lagged = n,
actor = %self.username,
"Admin event channel lagged, re-subscribing and verifying membership"
);
admin_rx = self.event_service.subscribe_admin_events();
match self.realtime_access_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
reason,
"Real-time access is no longer valid (detected after admin event lag), disconnecting"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to verify membership after admin event lag"
);
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::error!("Admin event channel closed");
break;
}
}
}
// When notification_service is configured, notifications are pushed
// directly without depending on the gRPC bridge task.
result = async {
match notification_rx.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
match result {
Ok(event) => {
// Only push if this notification targets the connected user
if self.principal.user_id() == Some(event.user_id) {
let msg = ServerMessage {
message: Some(synctv_proto::client::server_message::Message::Notification(
synctv_proto::client::UserNotification {
notification_id: event.notification.id.to_string(),
notification_type: crate::impls::notification::notification_type_to_proto(
event.notification.notification_type,
) as i32,
title: event.notification.title,
content: event.notification.content,
data: Some(crate::impls::notification::notification_data_to_proto(
&event.notification.data,
)),
timestamp: event.notification.created_at.timestamp(),
},
)),
};
if let Err(e) = stream.send(msg) {
tracing::error!("Failed to push direct notification to WebSocket: {}", e);
break;
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
lagged = n,
actor = %self.username,
"Notification event channel lagged, re-subscribing"
);
notification_rx = self
.notification_service
.as_ref()
.map(|svc| svc.subscribe_events());
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::debug!("Notification event channel closed");
notification_rx = None;
}
}
}
// Heartbeat/health check every 30 seconds.
// Also acts as a periodic membership re-validation backstop:
// verifies the user is still a valid (active member of the room. This catches cases where the disconnect
// signal channel lagged and the room kick or platform ban signal was lost.
// Uses the membership cache to reduce database queries: if a
// cached entry exists and shows the user as a valid member, the
// DB query is skipped. When a KickUser or KickUserFromRoom admin
// event arrives, the cache entry is invalidated immediately,
// forcing the next heartbeat to re-query the DB.
_ = heartbeat_interval.tick() => {
if !stream.is_alive() {
tracing::info!("Connection no longer alive");
break;
}
if let Err(e) = stream.ping() {
tracing::info!("Ping failed, connection dead: {}", e);
break;
}
if self.principal.is_guest() {
match self.guest_admission_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
room_id = %self.room_id,
actor = %self.username,
reason,
"Periodic check: guest access is no longer valid, disconnecting"
);
self.send_realtime_termination(
stream,
"Guest access to this room has ended",
RealtimeTerminationCode::GuestAccessRevoked,
);
break;
}
Ok(None) => continue,
Err(error) => {
tracing::warn!(
error = %error,
actor = %self.username,
"Periodic guest access check failed (will retry)"
);
continue;
}
}
}
// Check membership cache first to avoid unnecessary DB queries.
let Some(user_id) = self.principal.user_id() else {
continue;
};
let cache_key = (self.room_id, user_id);
if let Some(cached) = self.membership_cache.get(&cache_key) {
if !cached.is_member {
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
"Periodic check (cached): user is no longer a member, disconnecting"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
// Cache hit with valid member status -- skip DB query
continue;
}
// Cache miss: query database and populate cache.
match self
.membership_probe()
.probe_realtime_membership_access(&self.room_id, &user_id)
.await
{
Ok(RealtimeMembershipAccess::Allowed(member)) => {
let cached = CachedMembership::from_member(Some(&member));
self.membership_cache.insert(cache_key, cached);
}
Ok(RealtimeMembershipAccess::Denied(reason)) => {
let cached = CachedMembership::from_member(None);
self.membership_cache.insert(cache_key, cached);
tracing::info!(
actor = %self.username,
room_id = %self.room_id,
reason,
"Periodic check: real-time access is no longer valid, disconnecting"
);
self.send_realtime_termination(
stream,
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
Err(e) => {
// Log but don't disconnect — transient DB error should not
// kick valid users. Will retry on the next 30-second tick.
// Don't cache the error -- next tick will retry.
tracing::warn!(
error = %e,
actor = %self.username,
"Periodic membership check failed (will retry)"
);
}
}
}
}
}
// Cleanup: notify cluster that user left
self.cleanup(&room_id_str).await;
Ok(())
}
/// Broadcast `UserJoined` event to cluster replicas.
async fn broadcast_user_joined(
&self,
member: Option<&synctv_core::models::RoomMember>,
room_settings: Option<&synctv_core::models::RoomSettings>,
) {
let actor = match self.realtime_actor() {
Ok(actor) => actor,
Err(error) => {
tracing::error!(
room_id = %self.room_id,
error = %error,
"Skipping joined broadcast because actor encoding failed"
);
return;
}
};
match self
.presence_service
.actor_has_other_connection_in_room(&actor, self.room_id, self.connection_id.as_str())
.await
{
Ok(true) => {
tracing::debug!(
room_id = %self.room_id,
actor = %self.username,
connection_id = %self.connection_id,
"Skipping UserJoined broadcast because the user is already present in the room on another connection"
);
return;
}
Err(error) => {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
connection_id = %self.connection_id,
"Distributed same-user presence lookup failed during join; continuing with UserJoined broadcast to avoid missing online signal"
);
}
Ok(false) => {}
}
let (
role,
permissions,
added_permissions,
removed_permissions,
admin_added_permissions,
admin_removed_permissions,
) = if let Some(identity) = self.principal.guest_identity() {
(
synctv_core::models::RoomRole::Guest,
identity.permissions,
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
)
} else {
match member {
Some(member) => {
let Some(settings) = room_settings else {
tracing::error!(
room_id = %self.room_id,
actor = %self.username,
connection_id = %self.connection_id,
"Skipping UserJoined broadcast because room settings are missing"
);
return;
};
let effective = self
.room_service
.permission_service()
.effective_member_permissions(member, settings);
(
member.role,
effective,
synctv_core::models::RoomPermissionSet(member.added_permissions),
synctv_core::models::RoomPermissionSet(member.removed_permissions),
synctv_core::models::RoomPermissionSet(member.admin_added_permissions),
synctv_core::models::RoomPermissionSet(member.admin_removed_permissions),
)
}
None => {
// Fallback: if we can't fetch membership, use Member defaults
(
synctv_core::models::RoomRole::Member,
synctv_core::models::RoomPermissionSet::default_member(),
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
synctv_core::models::RoomPermissionSet(0),
)
}
}
};
let event = match actor {
synctv_core::models::RealtimeActor::Guest { guest_id } => RealtimeEvent::GuestJoined {
event_id: synctv_common::snanoid!(16),
room_id: self.room_id,
guest_id,
username: self.username.clone(),
permissions,
role,
joined_at: self.clock.now(),
timestamp: self.clock.now(),
},
synctv_core::models::RealtimeActor::User { user_id, .. } => RealtimeEvent::UserJoined {
event_id: synctv_common::snanoid!(16),
room_id: self.room_id,
user_id,
username: self.username.clone(),
remark_name: String::new(),
display_tag: String::new(),
permissions,
role,
added_permissions,
removed_permissions,
admin_added_permissions,
admin_removed_permissions,
joined_at: self.clock.now(),
timestamp: self.clock.now(),
},
};
let outcome = self.event_service.broadcast_outcome(event);
if outcome.distributed_delivery_missed() {
tracing::warn!(
room_id = %self.room_id,
actor = %self.username,
"UserJoined broadcast missed the distributed fan-out path (non-critical: join is local-only)"
);
}
}
/// Cleanup on disconnect
async fn cleanup(&self, room_id: &str) {
self.resource_observer.clear_observations().await;
self.leave_all_media_swarms().await;
let actor = match self.realtime_actor() {
Ok(actor) => actor,
Err(error) => {
tracing::error!(error = %error, "Realtime actor encoding failed during cleanup");
self.connection_service
.unregister(self.connection_id.as_str())
.await;
self.event_service.unsubscribe(self.connection_id.as_str());
return;
}
};
// If this connection had an active WebRTC session, decrement the metric
// and broadcast WebRTCVoicePeerLeft so other peers can clean up.
// Use Acquire ordering to synchronize with the Release store in handle_webrtc_voice_join/leave_webrtc_voice_session.
// IMPORTANT: We must check if the connection is STILL marked as RTC-joined
// in the connection manager before decrementing the metric. This prevents
// a race condition where:
// 1. Cleanup task times out the WebRTC session (mark_voice_rtc_joined(false))
// 2. Connection ungracefully disconnects
// 3. cleanup() sees has_voice_rtc_session=true and decrements the metric again
// Result: Metric underflow (negative value)
// By checking the connection manager's state, we ensure idempotency:
// - If the cleanup task already timed out the session, the connection
// manager will have voice_rtc_joined=false, and we skip the decrement
// - If the user explicitly left WebRTC, the flag is already false, and we skip
// - Only if the connection truly had an active session do we decrement
if self
.has_voice_rtc_session
.swap(false, std::sync::atomic::Ordering::Acquire)
{
let should_publish_leave = match self
.connection_service
.leave_voice_rtc(&self.room_id, &actor, self.connection_id.as_str())
.await
{
Ok(left) => left,
Err(error) => {
// unregister() will retry Redis slot cleanup while the local joined flag
// remains set after a distributed metadata synchronization failure.
tracing::warn!(
room_id = %self.room_id,
actor = %self.username,
connection_id = %self.connection_id,
error = %error,
"Voice chat leave cleanup failed; unregister will continue cleanup"
);
self.connection_service
.get_connection(self.connection_id.as_str())
.is_some_and(|connection| connection.voice_rtc_joined)
}
};
if should_publish_leave {
synctv_core::metrics::application::WEBRTC_PEERS_ACTIVE.dec();
// Broadcast WebRTCVoicePeerLeft so other peers know this user dropped
match self.public_actor_id() {
Ok(actor_id) => {
self.event_service
.broadcast(RealtimeEvent::WebRTCVoicePeerLeft {
event_id: synctv_common::snanoid!(16),
room_id: self.room_id,
actor_id,
conn_id: self.connection_id.as_str().to_string(),
timestamp: self.clock.now(),
});
}
Err(error) => {
tracing::error!(
room_id = %self.room_id,
actor = %self.username,
connection_id = %self.connection_id,
error = %error,
"Skipping WebRTC leave broadcast because actor public id encoding failed"
);
}
}
tracing::info!(
user = %self.username,
room = %room_id,
connection = %self.connection_id,
"WebRTC session cleaned up on disconnect"
);
} else {
// Session was already cleaned up by timeout task or explicit leave
// Just clear the connection manager state (idempotent)
tracing::debug!(
user = %self.username,
room = %room_id,
connection = %self.connection_id,
"WebRTC session already cleaned up (skipped metric decrement and broadcast)"
);
}
}
// If the disconnect was triggered by a realtime event that already
// published UserLeft, skip the redundant broadcast to avoid duplicate
// UserLeft events.
if self
.skip_cleanup_user_left
.load(std::sync::atomic::Ordering::Relaxed)
{
tracing::debug!(
user = %self.username,
room = %room_id,
"Skipping UserLeft broadcast in cleanup (already published by API call)"
);
// Still unregister from connection manager
self.connection_service
.unregister(self.connection_id.as_str())
.await;
self.event_service.unsubscribe(self.connection_id.as_str());
return;
}
let has_other_local_connection = self
.connection_service
.get_actor_connections(&actor)
.into_iter()
.any(|conn| {
conn.connection_id != self.connection_id.as_str()
&& conn
.room_id
.as_ref()
.is_some_and(|rid| rid == &self.room_id)
});
let should_broadcast_left = match self
.presence_service
.actor_has_other_connection_in_room(&actor, self.room_id, self.connection_id.as_str())
.await
{
Ok(has_other_connection) => {
should_broadcast_user_left(has_other_local_connection, Ok(has_other_connection))
}
Err(error) => {
tracing::warn!(
error = %error,
user = %self.username,
room = %room_id,
connection = %self.connection_id,
"Distributed same-user presence lookup failed during cleanup; using local presence fallback for UserLeft broadcast"
);
should_broadcast_user_left(has_other_local_connection, Err(()))
}
};
// Broadcast UserLeft BEFORE unregistering from the connection manager.
// This order prevents state divergence: if the broadcast reaches subscribers
// while this connection is still registered, they see a consistent view.
// Previously, unregistering first could leave the hub with a stale subscriber
// if the broadcast was delayed or had no receivers.
let event = match actor {
synctv_core::models::RealtimeActor::Guest { guest_id } => RealtimeEvent::GuestLeft {
event_id: synctv_common::snanoid!(16),
room_id: self.room_id,
guest_id,
username: self.username.clone(),
timestamp: self.clock.now(),
},
synctv_core::models::RealtimeActor::User { user_id, .. } => {
let role = self
.current_room_role
.load(std::sync::atomic::Ordering::Relaxed);
let role = synctv_core::models::RoomRole::try_from(role)
.expect("current room role must contain a valid role");
RealtimeEvent::UserLeft {
event_id: synctv_common::snanoid!(16),
room_id: self.room_id,
user_id,
username: self.username.clone(),
remark_name: String::new(),
display_tag: String::new(),
role,
timestamp: self.clock.now(),
}
}
};
let result = if should_broadcast_left {
Some(self.event_service.broadcast_outcome(event))
} else {
tracing::debug!(
user = %self.username,
room = %room_id,
connection = %self.connection_id,
"Skipping UserLeft broadcast in cleanup because another connection for the same user remains in the room"
);
None
};
if let Some(outcome) = result {
if outcome.distributed_delivery_missed() {
tracing::warn!(
user = %self.username,
room = %room_id,
connection = %self.connection_id,
local_delivered = outcome.local_delivered(),
distributed_available = outcome.distributed_available(),
"UserLeft distributed publish missed during cleanup"
);
}
}
// Now unregister from connection manager after broadcast has been sent
self.connection_service
.unregister(self.connection_id.as_str())
.await;
self.event_service.unsubscribe(self.connection_id.as_str());
tracing::info!(
"Cleanup complete for user {} in room {} (connection: {})",
self.username,
room_id,
self.connection_id
);
}
/// Start the message handling loop
///
/// This method:
/// 1. Registers the connection and joins the room (enforcing connection limits)
/// 2. Subscribes to realtime events and forwards them to the client
/// 3. Spawns a task to handle incoming client messages
/// 4. Returns a sender and a cancellation token for the caller to manage lifecycle
///
/// Returns a tuple of (sender, `CancellationToken`), or an error if connection limits
/// are exceeded. Drop the `CancellationToken` or call `cancel()` on it to stop the
/// spawned tasks and trigger cleanup (unregister, unsubscribe).
pub async fn start(
&self,
) -> Result<
(
tokio::sync::mpsc::Sender<ClientMessage>,
tokio_util::sync::CancellationToken,
),
String,
> {
// Register connection with connection manager
let actor = self.realtime_actor()?;
self.connection_service
.register_actor(self.connection_id.clone().into_string(), actor)
.await?;
self.pre_join_after_registration()
.await
.map_err(String::from)?;
let cancel_token = tokio_util::sync::CancellationToken::new();
let room_id_str = self.public_room_id()?;
// Fetch member data and room settings once and reuse them for the join
// payload and realtime event. Authenticated users must have both so
// outbound permission snapshots cannot silently fall back to role-only
// defaults when a read fails.
let initial_join = self.take_initial_realtime_join_state(&room_id_str).await?;
if let Some(member) = initial_join.member.as_ref() {
self.current_room_role
.store(i32::from(member.role), std::sync::atomic::Ordering::Relaxed);
}
self.broadcast_user_joined(
initial_join.member.as_ref(),
initial_join.room_settings.as_ref(),
)
.await;
// Use bounded channel to prevent memory exhaustion from fast clients
let (tx, mut rx) = tokio::sync::mpsc::channel::<ClientMessage>(256);
let room_id_str = self.public_room_id()?;
let event_connection_id = self.connection_id.as_str().to_string();
let mut rx_events = match self.take_room_event_subscription().await {
Ok(rx_events) => rx_events,
Err(error) => {
self.cleanup(&room_id_str).await;
return Err(error);
}
};
let sender = self.sender.clone();
let event_handler = self.clone();
let public_id_codec = self.public_id_codec.clone();
let event_token = cancel_token.clone();
spawn_monitored("messaging_event_dispatch", async move {
loop {
tokio::select! {
() = event_token.cancelled() => break,
event = rx_events.recv() => {
match event {
Some(event) => {
if let Some(reason) = guest_access_revocation_reason(
&event_handler.principal,
&event,
)
{
tracing::info!(
room_id = %event_handler.room_id,
actor = %event_handler.username,
reason,
"Guest access was revoked in start(), disconnecting"
);
if let Err(error) = event_handler.send_server_message(
realtime_termination_server_message(
"Guest access to this room has ended",
RealtimeTerminationCode::GuestAccessRevoked,
),
) {
tracing::debug!(
error = %error,
"Failed to send guest access termination before cancellation"
);
}
event_token.cancel();
break;
}
event_handler.apply_connection_state_from_room_event(&event);
event_handler.apply_rtc_access_change(&event).await;
let is_room_shutdown = matches!(
event.as_ref(),
RealtimeEvent::RoomDeleted { .. }
| RealtimeEvent::RoomBanned { .. }
| RealtimeEvent::RoomOwnerInactive { .. }
);
match event_handler
.webrtc_event_server_message_for_current_connection(&event)
{
Ok(Some(message)) => {
if let Err(error) = sender.send(message) {
tracing::error!(
"Failed to send WebRTC server message: {}",
error
);
event_token.cancel();
break;
}
}
Ok(None) => {}
Err(error) => {
tracing::error!(
room_id = %event_handler.room_id,
event_id = %event.event_id(),
error = %error,
"Failed to convert WebRTC event to server message"
);
event_token.cancel();
break;
}
}
if !matches!(event.as_ref(), RealtimeEvent::ChatMessageEvent { .. }) {
let messages = match realtime_event_to_server_messages(
&event,
&room_id_str,
&public_id_codec,
) {
Ok(messages) => messages,
Err(error) => {
tracing::error!(
room_id = %event_handler.room_id,
event_id = %event.event_id(),
error = %error,
"Failed to convert realtime event to server message"
);
event_token.cancel();
break;
}
};
for msg in messages {
if let Err(e) = event_handler.send_server_message(msg) {
tracing::error!("Failed to send message: {}", e);
event_token.cancel();
break;
}
}
}
if let Err(error) =
event_handler
.resource_observer
.room_hub
.refresh_for_room_event(&event, Some(&event_connection_id))
.await
{
tracing::error!(
"Failed to refresh observed resources in start(): {}",
error
);
event_token.cancel();
break;
}
// After delivering a terminal room-wide admin event, trigger cancellation so
// cleanup fires only after the event has been forwarded.
// This prevents the race where the cleanup task fires
// before the critical event reaches the client.
if is_room_shutdown {
tracing::info!(
"Terminal room event delivered in start(), triggering cleanup"
);
event_token.cancel();
break;
}
}
None => break,
}
}
() = event_handler
.resource_observer
.wait_for_expired_resource_refresh_deadline() => {
if let Err(error) = event_handler
.resource_observer
.refresh_expired_resource_observations()
.await
{
tracing::error!(
"Failed to refresh observed resources in start(): {}",
error
);
event_token.cancel();
break;
}
}
}
}
});
// Spawn task to handle incoming messages (with rate limiting matching run())
let handler = self.clone();
let msg_token = cancel_token.clone();
let global_msg_rate_limit = self.ws_message_rate_limit;
spawn_monitored("messaging_client_handler", async move {
let mut global_msg_count: u32 = 0;
let mut global_msg_window_start = tokio::time::Instant::now();
let message_control = ExecutionControl::from_parts(None, msg_token.clone());
loop {
tokio::select! {
() = msg_token.cancelled() => break,
msg = rx.recv() => {
match msg {
Some(msg) => {
handler.connection_service.record_message(&handler.connection_id);
// Global per-connection rate limit check (matching run() logic)
let now = tokio::time::Instant::now();
if now.duration_since(global_msg_window_start) >= std::time::Duration::from_secs(1) {
global_msg_count = 0;
global_msg_window_start = now;
}
global_msg_count += 1;
if global_msg_count > global_msg_rate_limit {
tracing::warn!(
connection_id = %handler.connection_id,
limit = global_msg_rate_limit,
"gRPC start() message rate limit exceeded, dropping message"
);
continue;
}
// Backpressure control: try to acquire a semaphore permit.
// If the system is overloaded, skip this message.
let semaphore = handler.concurrency_config.semaphore();
let Ok(permit) = semaphore.try_acquire_owned() else {
tracing::warn!(
connection_id = %handler.connection_id,
"System overloaded: message processing semaphore exhausted in start()"
);
continue;
};
// Process message with semaphore permit held
let _permit = permit;
if let Err(e) = handler
.handle_client_message_with_control(&msg, Some(&message_control))
.await
{
tracing::error!("Failed to handle client message: {}", e);
if let Err(send_err) = handler.sender.send(
Self::operation_error_server_message(
e.clone(),
Self::client_operation_id(&msg),
),
) {
tracing::error!(
"Failed to send message error to client in start(): {}",
send_err
);
msg_token.cancel();
break;
}
}
}
None => break,
}
}
}
}
});
// Spawn task to monitor disconnect signals and admin events.
// When a relevant signal is received, cancel the token to stop all other tasks.
{
let mut disconnect_rx = self.connection_service.subscribe_disconnect();
let mut admin_rx = self.event_service.subscribe_admin_events();
let disconnect_token = cancel_token.clone();
let connection_id = self.connection_id.as_str().to_string();
let user_id = self.principal.user_id();
let room_id = self.room_id;
let event_service = Arc::clone(&self.event_service);
let connection_service = self.connection_service.clone();
let admin_sender = self.sender.clone();
let admin_handler = self.clone();
let skip_cleanup_user_left = Arc::clone(&self.skip_cleanup_user_left);
spawn_monitored("messaging_disconnect_monitor", async move {
loop {
tokio::select! {
() = disconnect_token.cancelled() => break,
signal = disconnect_rx.recv() => {
let termination = match &signal {
Ok(synctv_realtime::sync::DisconnectSignal::Connection(conn_id))
if *conn_id == connection_id => Some(realtime_termination_server_message(
"Connection closed by server",
RealtimeTerminationCode::ConnectionRevoked,
)),
Ok(synctv_realtime::sync::DisconnectSignal::User(uid))
if user_id == Some(*uid) => Some(realtime_termination_server_message(
"Your account access has been revoked",
RealtimeTerminationCode::UserAccessRevoked,
)),
Ok(synctv_realtime::sync::DisconnectSignal::Room {
room_id: rid,
reason,
}) if *rid == room_id => {
Some(room_disconnect_termination_server_message(*reason))
}
Ok(synctv_realtime::sync::DisconnectSignal::UserFromRoom { user_id: uid, room_id: rid })
if user_id == Some(*uid) && *rid == room_id => Some(realtime_termination_server_message(
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
)),
_ => None,
};
let should_disconnect = termination.is_some()
|| matches!(&signal, Err(tokio::sync::broadcast::error::RecvError::Closed));
// Handle lag separately (needs mutable borrow of disconnect_rx)
if let Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) = signal {
tracing::warn!(
lagged = n,
?user_id,
"Disconnect signal channel lagged in start(), re-subscribing and verifying"
);
disconnect_rx = connection_service.subscribe_disconnect();
{
match admin_handler.realtime_access_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
?user_id,
room_id = %room_id,
reason,
"start() real-time access is no longer valid after disconnect signal lag"
);
if let Err(error) = admin_handler.send_server_message(
realtime_termination_server_message(
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
),
) {
tracing::debug!(error = %error, "Failed to send realtime termination after disconnect signal lag");
}
skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
disconnect_token.cancel();
break;
}
Ok(None) => {}
Err(error) => {
tracing::warn!(
error = %error,
?user_id,
room_id = %room_id,
"start() failed to verify membership after disconnect signal lag"
);
}
}
}
} else if should_disconnect {
if let Ok(signal) = &signal {
if disconnect_signal_requires_skip_cleanup(signal, user_id, &room_id, &connection_id) {
skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
if let Some(termination) = termination {
if let Err(error) = admin_handler.send_server_message(termination) {
tracing::debug!(error = %error, "Failed to send realtime termination before disconnect cancellation");
}
}
tracing::info!(
connection_id = %connection_id,
"Disconnect signal received in start(), cancelling"
);
disconnect_token.cancel();
break;
}
}
admin_event = admin_rx.recv() => {
// Uses the dedicated Notification variant (not ErrorMessage abuse).
if let Ok(RealtimeEvent::UserNotification { user_id: uid, title, content, notification_type, data, notification_id, timestamp, .. }) = &admin_event {
if user_id == Some(*uid) {
let msg = user_notification_server_message(
notification_id.clone(),
*notification_type,
data,
title.clone(),
content.clone(),
*timestamp,
);
if let Err(e) = admin_sender.send(msg) {
tracing::error!("Failed to push notification in start(): {}", e);
disconnect_token.cancel();
break;
}
}
continue;
}
if let Ok(RealtimeEvent::SystemNotification { message, timestamp, .. }) = &admin_event {
let msg = match system_notification_server_message(message.clone(), *timestamp) {
Ok(msg) => msg,
Err(error) => {
tracing::error!(error = %error, "Invalid system notification realtime event");
disconnect_token.cancel();
break;
}
};
if let Err(e) = admin_sender.send(msg) {
tracing::error!(
"Failed to push system notification in start(): {}",
e
);
disconnect_token.cancel();
break;
}
continue;
}
if let Ok(RealtimeEvent::ProviderCredentialChanged { event_id, user_id: changed_user_id, provider, server_id, .. }) = &admin_event {
admin_handler
.resource_observer
.handle_provider_credential_changed_admin_event(
event_id,
changed_user_id,
*provider,
server_id,
)
.await;
continue;
}
if let Ok(RealtimeEvent::CacheInvalidate { event_id, targets, .. }) = &admin_event
{
admin_handler
.resource_observer
.handle_cache_invalidate_admin_event(event_id, targets)
.await;
continue;
}
let termination = match &admin_event {
Ok(RealtimeEvent::KickUser { user_id: uid, .. })
if user_id == Some(*uid) => Some(realtime_termination_server_message(
"Your account access has been revoked",
RealtimeTerminationCode::UserAccessRevoked,
)),
Ok(RealtimeEvent::KickUserFromRoom { user_id: uid, room_id: rid, .. })
if user_id == Some(*uid) && *rid == room_id => Some(realtime_termination_server_message(
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
)),
_ => None,
};
let should_disconnect = termination.is_some()
|| matches!(&admin_event, Err(tokio::sync::broadcast::error::RecvError::Closed));
// Handle lag separately
if let Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) = admin_event {
tracing::warn!(
lagged = n,
?user_id,
"Admin event channel lagged in start(), re-subscribing and verifying"
);
admin_rx = event_service.subscribe_admin_events();
{
match admin_handler.realtime_access_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
?user_id,
room_id = %room_id,
reason,
"start() real-time access is no longer valid after admin event lag"
);
if let Err(error) = admin_handler.send_server_message(
realtime_termination_server_message(
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
),
) {
tracing::debug!(error = %error, "Failed to send realtime termination after admin event lag");
}
skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
disconnect_token.cancel();
break;
}
Ok(None) => {}
Err(error) => {
tracing::warn!(
error = %error,
?user_id,
room_id = %room_id,
"start() failed to verify membership after admin event lag"
);
}
}
}
} else if should_disconnect {
if let Ok(event) = &admin_event {
if admin_event_requires_skip_cleanup(event, user_id, &room_id) {
skip_cleanup_user_left.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
if let Some(termination) = termination {
if let Err(error) = admin_handler.send_server_message(termination) {
tracing::debug!(error = %error, "Failed to send realtime termination before admin disconnect cancellation");
}
}
tracing::info!(
connection_id = %connection_id,
"Admin event triggered disconnect in start(), cancelling"
);
disconnect_token.cancel();
break;
}
}
}
}
});
}
// Spawn periodic heartbeat task for membership re-validation (mirrors run() behavior).
// Verifies every 25-35 seconds that the user is still a valid, member.
// Jitter prevents the thundering-herd problem where all 1000+ concurrent connections
// fire their DB membership checks simultaneously at the same 30-second boundary.
// This catches cases where disconnect signals were lost (e.g., channel lag).
{
let heartbeat_token = cancel_token.clone();
let heartbeat_room_id = self.room_id;
let heartbeat_actor = self.realtime_actor()?;
let heartbeat_sender = Arc::clone(&self.sender);
let heartbeat_schedule = self.heartbeat_schedule;
let skip_cleanup_user_left = Arc::clone(&self.skip_cleanup_user_left);
let heartbeat_handler = self.clone();
spawn_monitored("messaging_heartbeat", async move {
// Derive jitter from the typed actor key so each connection gets a
// stable-but-different offset within the 2535 s window.
let period = heartbeat_schedule.period_for_actor(&heartbeat_actor);
let mut interval = tokio::time::interval(period);
interval.tick().await; // Skip the immediate first tick
loop {
tokio::select! {
() = heartbeat_token.cancelled() => break,
_ = interval.tick() => {
// Check connection liveness first (mirrors run() behavior)
if !heartbeat_sender.is_alive() {
tracing::info!("start() connection no longer alive");
heartbeat_token.cancel();
break;
}
if let Err(e) = heartbeat_sender.ping() {
tracing::info!("start() ping failed, connection dead: {}", e);
heartbeat_token.cancel();
break;
}
match heartbeat_handler.realtime_access_denial_reason().await {
Ok(Some(reason)) => {
tracing::info!(
actor = %heartbeat_actor,
room_id = %heartbeat_room_id,
reason,
"start() periodic check: real-time access is no longer valid, disconnecting"
);
let (message, code) = if heartbeat_actor.is_guest() {
(
"Guest access to this room has ended",
RealtimeTerminationCode::GuestAccessRevoked,
)
} else {
(
"Your room membership has ended",
RealtimeTerminationCode::RoomMembershipRevoked,
)
};
if let Err(error) = heartbeat_handler.send_server_message(
realtime_termination_server_message(message, code),
) {
tracing::debug!(error = %error, "Failed to send realtime termination after access check");
}
if !heartbeat_actor.is_guest() {
skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
}
heartbeat_token.cancel();
break;
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
error = %e,
actor = %heartbeat_actor,
"start() periodic access check failed (will retry)"
);
}
}
}
}
}
});
}
// Spawn cleanup task that waits for cancellation
let cleanup_handler = self.clone();
let cleanup_room_id = self.public_room_id()?;
let cleanup_token = cancel_token.clone();
spawn_monitored("messaging_cleanup", async move {
cleanup_token.cancelled().await;
cleanup_handler.cleanup(&cleanup_room_id).await;
});
Ok((tx, cancel_token))
}
}
impl StreamMessageHandler {
/// Handle incoming client message with all validations
#[cfg(test)]
async fn handle_client_message(&self, msg: &ClientMessage) -> Result<(), String> {
self.handle_client_message_with_control(msg, None).await
}
pub async fn handle_client_message_with_control(
&self,
msg: &ClientMessage,
control: Option<&ExecutionControl>,
) -> Result<(), String> {
use synctv_proto::client::client_message::Message;
match &msg.message {
Some(Message::Chat(chat_msg)) => {
if self.principal.is_guest() {
return Err("Guests cannot send chat messages".to_string());
}
// ChatService handles permissions, room settings, rate limiting,
// content filtering, persistence, and event dispatch.
self.handle_chat_message_with_control(chat_msg, control)
.await?;
}
Some(Message::Heartbeat(_)) => {
// Respond with HeartbeatAck to let client know server is alive
// This completes the heartbeat request-response cycle
self.send_heartbeat_ack()?;
}
Some(Message::Webrtc(command)) => {
self.handle_webrtc_command(command).await?;
}
Some(Message::PlaybackUpdate(update)) => {
self.handle_playback_source_update(update).await?;
}
Some(Message::PlaybackStateUpdate(update)) => {
self.handle_playback_state_update(update).await?;
}
Some(Message::ObserveResource(observe)) => {
self.ensure_observe_resource_allowed(observe).await?;
self.resource_observer
.handle_observe_resource(observe)
.await?;
self.resource_observer
.replay_chat_events_after(observe)
.await?;
self.resource_observer
.replay_room_resource_events_after(observe)
.await?;
}
Some(Message::UnobserveResource(unobserve)) => {
self.resource_observer
.handle_unobserve_resource(unobserve)
.await?;
}
None => {
return Err("Empty message".to_string());
}
}
Ok(())
}
async fn handle_chat_message_with_control(
&self,
chat_msg: &synctv_proto::client::ChatMessageSend,
control: Option<&ExecutionControl>,
) -> Result<(), String> {
if self.principal.is_guest() {
self.ensure_guest_admission_for_action().await?;
return Err("Guests cannot send chat messages".to_string());
}
// Delegate to ChatService which handles permission checks, content filtering,
// rate limiting, and persistence (no fallback path).
let attachments = crate::impls::client::parse_proto_chat_attachments(&chat_msg.attachments)
.map_err(|error| error.to_string())?;
let reply_to_message_id = parse_optional_chat_message_id(&chat_msg.reply_to_message_id)?;
let playback_state = self
.room_service
.playback_service()
.get_state(&self.room_id)
.await
.map_err(|error| format!("Failed to load playback state for chat metadata: {error}"))?;
let playback_metadata = self
.room_service
.playback_service()
.chat_playback_metadata_for_state(&playback_state, playback_state.computed_position())
.await
.map_err(|error| {
format!("Failed to load playback source metadata for chat: {error}")
})?;
let metadata = chat_metadata_for_send(
chat_metadata_from_proto(chat_msg.metadata.as_ref())
.map_err(|error| error.to_string())?,
&chat_msg.display_position,
&chat_msg.display_color,
playback_metadata,
)?;
let outcome = self
.chat_service
.send_message_event_with_control_outcome(
SendChatMessage {
room_id: self.room_id,
user_id: self.require_user_id()?,
client_message_id: (!chat_msg.client_message_id.trim().is_empty())
.then(|| chat_msg.client_message_id.trim().to_string()),
content: chat_msg.content.clone(),
message_type: ChatMessageType::User,
reply_to_message_id,
metadata,
attachments,
mentions: proto_chat_mentions_to_core(
&chat_msg.mentions,
&self.public_id_codec,
)?,
},
control,
)
.await
.map_err(|e| e.to_string())?;
// Touch room activity to prevent TTL expiry on active rooms
self.room_service.touch_room_activity(self.room_id).await;
// Track chat message metric
synctv_core::metrics::application::CHAT_MESSAGES_TOTAL.inc();
if outcome.inserted {
self.chat_event_dispatcher.dispatch(&outcome.event);
}
Ok(())
}
async fn ensure_guest_admission_for_action(&self) -> Result<(), String> {
match self.guest_admission_denial_reason().await {
Ok(Some(reason)) => Err(reason),
Ok(None) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
/// Send heartbeat acknowledgment to client
fn send_heartbeat_ack(&self) -> Result<(), String> {
use synctv_proto::client::server_message::Message;
use synctv_proto::client::HeartbeatAck;
let msg = ServerMessage {
message: Some(Message::HeartbeatAck(HeartbeatAck {
timestamp: self.clock.now_millis(),
})),
};
self.sender.send(msg)
}
}
mod event_messages;
use event_messages::{
realtime_event_to_server_messages, realtime_termination_server_message,
room_disconnect_termination_server_message,
};
impl StreamMessageHandler {
async fn take_initial_realtime_join_state(
&self,
room_id_str: &str,
) -> Result<InitialRealtimeJoinState, String> {
if let Some(state) = self.pending_initial_join_state.lock().await.take() {
return Ok(state);
}
if self.principal.is_guest() {
return Ok(InitialRealtimeJoinState {
member: None,
room_settings: None,
});
}
let user_id = self.require_user_id()?;
let member_lookup = self
.membership_probe()
.probe_realtime_membership_access(&self.room_id, &user_id)
.await;
let member = match member_lookup {
Ok(RealtimeMembershipAccess::Allowed(member)) => member,
Ok(RealtimeMembershipAccess::Denied(reason)) => {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.cleanup(room_id_str).await;
return Err(reason);
}
Err(error) => {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to fetch membership during initial real-time join"
);
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.cleanup(room_id_str).await;
return Err(error.to_string());
}
};
let room_settings = self
.room_service
.get_room_settings(&self.room_id)
.await
.map_err(|error| {
tracing::warn!(
error = %error,
room_id = %self.room_id,
actor = %self.username,
"Failed to fetch room settings during initial real-time join"
);
error.to_string()
});
let room_settings = match room_settings {
Ok(room_settings) => room_settings,
Err(error) => {
self.skip_cleanup_user_left
.store(true, std::sync::atomic::Ordering::Relaxed);
self.cleanup(room_id_str).await;
return Err(error);
}
};
Ok(InitialRealtimeJoinState {
member: Some(member),
room_settings: Some(room_settings),
})
}
}
fn parse_optional_chat_message_id(raw: &str) -> Result<Option<i64>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
trimmed
.parse::<i64>()
.map(Some)
.map_err(|_| "Invalid chat message id".to_string())
}
fn proto_chat_mentions_to_core(
mentions: &[synctv_proto::client::ChatMentionInput],
public_id_codec: &synctv_adapter::PublicIdCodec,
) -> Result<Vec<ChatMentionInput>, String> {
mentions
.iter()
.map(|mention| {
let user_id = public_id_codec
.decode_user_id(&mention.user_id)
.map_err(|error| format!("Invalid mention user_id: {error}"))?;
Ok(ChatMentionInput {
user_id,
start: mention.start,
length: mention.length,
})
})
.collect()
}
#[cfg(test)]
mod tests;