@ -59,7 +59,7 @@ 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 , ServerMessage} ;
use synctv_proto ::client ::{ ClientMessage , RealtimeTerminationCode, ServerMessage} ;
use synctv_realtime ::fanout ::RealtimeEventService ;
use synctv_realtime ::sync ::ConnectionRuntime ;
@ -215,6 +215,8 @@ pub struct StreamMessageHandler {
/// `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
@ -346,6 +348,7 @@ impl Clone for StreamMessageHandler {
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 ) ,
@ -379,6 +382,62 @@ impl StreamMessageHandler {
}
}
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 ! (
user_id = % self . user_id ,
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 ( ) {
@ -489,6 +548,7 @@ impl StreamMessageHandler {
active_media_swarms : Arc ::new ( parking_lot ::Mutex ::new ( BTreeSet ::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 ,
) ) ,
@ -1286,6 +1346,11 @@ impl StreamMessageHandler {
connection_id = % self . connection_id ,
"Received disconnect signal for this connection"
) ;
self . send_realtime_termination (
stream ,
"Connection closed by server" ,
RealtimeTerminationCode ::ConnectionRevoked ,
) ;
break ;
}
}
@ -1295,17 +1360,30 @@ impl StreamMessageHandler {
user_id = % self . user_id ,
"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 ( rid ) ) = > {
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 ) ;
@ -1319,6 +1397,11 @@ impl StreamMessageHandler {
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 ) ;
@ -1349,6 +1432,11 @@ impl StreamMessageHandler {
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 ;
@ -1384,6 +1472,11 @@ impl StreamMessageHandler {
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 ,
@ -1404,6 +1497,11 @@ impl StreamMessageHandler {
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 ,
@ -1421,6 +1519,44 @@ impl StreamMessageHandler {
// UserLeft was already published by the leave_room
// or delete_room API call. Skip the redundant
// broadcast in cleanup().
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 ;
}
@ -1492,6 +1628,11 @@ impl StreamMessageHandler {
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 ;
@ -1592,6 +1733,11 @@ impl StreamMessageHandler {
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 ,
@ -1615,6 +1761,11 @@ impl StreamMessageHandler {
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 ;
@ -1642,6 +1793,11 @@ impl StreamMessageHandler {
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 ;
@ -2168,7 +2324,7 @@ impl StreamMessageHandler {
}
} ;
for msg in messages {
if let Err ( e ) = sender. send ( msg ) {
if let Err ( e ) = event_handler. send_server_message ( msg ) {
tracing ::error ! ( "Failed to send message: {}" , e ) ;
event_token . cancel ( ) ;
break ;
@ -2321,22 +2477,32 @@ impl StreamMessageHandler {
( ) = disconnect_token . cancelled ( ) = > break ,
signal = disconnect_rx . recv ( ) = > {
let should_disconnect = match & signal {
Ok ( synctv_realtime ::sync ::DisconnectSignal ::Connection ( conn_id ) ) = > {
* conn_id = = connection_id
}
Ok ( synctv_realtime ::sync ::DisconnectSignal ::User ( uid ) ) = > {
* uid = = user_id
}
Ok ( synctv_realtime ::sync ::DisconnectSignal ::Room ( rid ) ) = > {
* rid = = room_id
}
Ok ( synctv_realtime ::sync ::DisconnectSignal ::UserFromRoom { user_id : uid , room_id : rid } ) = > {
* uid = = user_id & & * rid = = room_id
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 * uid = = user_id = > 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 ) )
}
Err ( tokio ::sync ::broadcast ::error ::RecvError ::Lagged ( _ ) ) = > false ,
Err ( tokio ::sync ::broadcast ::error ::RecvError ::Closed ) = > true ,
Ok ( synctv_realtime ::sync ::DisconnectSignal ::UserFromRoom { user_id : uid , room_id : rid } )
if * uid = = user_id & & * 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 ! (
@ -2357,6 +2523,14 @@ impl StreamMessageHandler {
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 ;
@ -2378,6 +2552,11 @@ impl StreamMessageHandler {
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"
@ -2446,19 +2625,26 @@ impl StreamMessageHandler {
. await ;
continue ;
}
let should_disconnect = match & admin_event {
Ok ( RealtimeEvent ::KickUser { user_id : uid , .. } ) = > {
* uid = = user_id
}
Ok (
RealtimeEvent ::KickUserFromRoom { user_id : uid , room_id : rid , .. }
| RealtimeEvent ::UserLeft { user_id : uid , room_id : rid , .. } ,
) = > {
* uid = = user_id & & * rid = = room_id
}
Ok ( _ ) | Err ( tokio ::sync ::broadcast ::error ::RecvError ::Lagged ( _ ) ) = > false ,
Err ( tokio ::sync ::broadcast ::error ::RecvError ::Closed ) = > true ,
let termination = match & admin_event {
Ok ( RealtimeEvent ::KickUser { user_id : uid , .. } )
if * uid = = user_id = > Some ( realtime_termination_server_message (
"Your account access has been revoked" ,
RealtimeTerminationCode ::UserAccessRevoked ,
) ) ,
Ok ( RealtimeEvent ::KickUserFromRoom { user_id : uid , room_id : rid , .. } )
if * uid = = user_id & & * rid = = room_id = > Some ( realtime_termination_server_message (
"Your room membership has ended" ,
RealtimeTerminationCode ::RoomMembershipRevoked ,
) ) ,
Ok ( RealtimeEvent ::UserLeft { user_id : uid , room_id : rid , .. } )
if * uid = = user_id & & * 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 ! (
@ -2479,6 +2665,14 @@ impl StreamMessageHandler {
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 ;
@ -2500,6 +2694,11 @@ impl StreamMessageHandler {
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"
@ -2558,6 +2757,14 @@ impl StreamMessageHandler {
reason ,
"start() periodic check: guest access is no longer valid, disconnecting"
) ;
if let Err ( error ) = heartbeat_handler . send_server_message (
realtime_termination_server_message (
"Guest access to this room has ended" ,
RealtimeTerminationCode ::GuestAccessRevoked ,
) ,
) {
tracing ::debug ! ( error = % error , "Failed to send realtime termination after guest access check" ) ;
}
heartbeat_token . cancel ( ) ;
break ;
}
@ -2587,6 +2794,14 @@ impl StreamMessageHandler {
reason ,
"start() periodic check: real-time access is no longer valid, disconnecting"
) ;
if let Err ( error ) = heartbeat_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 membership check" ) ;
}
skip_cleanup_user_left
. store ( true , std ::sync ::atomic ::Ordering ::Relaxed ) ;
heartbeat_token . cancel ( ) ;
@ -2792,7 +3007,10 @@ impl StreamMessageHandler {
}
mod event_messages ;
use event_messages ::realtime_event_to_server_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 (