fix: classify forced realtime disconnects (#395)

## Summary

- send a typed realtime termination message before server-initiated
WebSocket disconnects
- restore the view_playback_history permission bit across effective
permissions and runtime settings round trips
- remove the unused common ErrorCode enum and document the dedicated
termination codes

## Validation

- make nextest: 6715 passed
- make clippy
- git diff --check
pull/397/head
zijiren 2 months ago committed by GitHub
parent da1abd1a68
commit 9bc010c568
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -119,6 +119,7 @@ Common server messages:
| `chat` | Chat message received |
| `heartbeatAck` | Heartbeat acknowledgement |
| `error` | General business error |
| `termination` | Server-initiated realtime stream termination; classify with the dedicated `RealtimeTerminationCode`, then reconnect |
| `playbackState` / `playingChanged` | Playback state or playback target changed |
| `roomSettings` | Room settings changed |
| `mediaAdded` / `mediaUpdated` / `mediaRemoved` | Media changed |
@ -152,6 +153,11 @@ All Realtime API business messages live in the `ClientMessage.message` and `Serv
```ts
function handleServerMessage(message) {
if (message.termination) {
handleRealtimeTermination(message.termination);
return;
}
if (message.chatEvent) {
appendChatEvent(message.chatEvent);
return;

@ -108,6 +108,7 @@ WebSocket business errors use protobuf messages, not HTTP JSON:
| Message | Scenario |
| --- | --- |
| `ServerMessage.error` | General business error, such as permission or invalid input |
| `ServerMessage.termination` | Server-initiated realtime stream termination with a dedicated `RealtimeTerminationCode` and display message |
| `ServerMessage.resourceObserveError` | Invalid observe id, too many observations, or resource load failure |
| WebSocket close | Auth failure, expired ticket, protocol error, connection limit, shutdown |

@ -119,6 +119,7 @@ WebRTC 客户端在发送信令前调用 `GET /api/rooms/<roomId>/webrtc/ice-ser
| `chat` | 收到聊天消息 |
| `heartbeatAck` | 心跳确认 |
| `error` | 普通业务错误 |
| `termination` | 服务端主动结束实时流;通过专用 `RealtimeTerminationCode` 分类,随后按重连流程处理 |
| `playbackState` / `playingChanged` | 播放状态或播放目标变化 |
| `roomSettings` | 房间设置变更广播 |
| `mediaAdded` / `mediaUpdated` / `mediaRemoved` | 媒体变更广播 |
@ -152,6 +153,11 @@ Realtime API 的所有业务消息都在 `ClientMessage.message` 和 `ServerMess
```ts
function handleServerMessage(message) {
if (message.termination) {
handleRealtimeTermination(message.termination);
return;
}
if (message.chatEvent) {
appendChatEvent(message.chatEvent);
return;

@ -113,6 +113,7 @@ WebSocket 业务错误通过 protobuf 消息返回,不是 HTTP JSON:
| 消息 | 场景 |
| --- | --- |
| `ServerMessage.error` | 普通业务错误,例如权限不足、输入无效 |
| `ServerMessage.termination` | 服务端主动结束实时流,包含专用 `RealtimeTerminationCode` 和展示文案 |
| `ServerMessage.resourceObserveError` | 资源观察失败,例如 observe id 无效、超过订阅上限、资源加载失败 |
| WebSocket close | 认证失败、ticket 失效、协议错误、连接限制或服务关闭 |

@ -218,7 +218,7 @@ impl AdminApiImpl {
prepared_outbox_fanout.publish_after_outbox_commit();
self.realtime_lifecycle
.disconnect_room(&rid, "room_batch_banned")
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Banned)
.await;
Ok::<(), ApiError>(())
@ -287,7 +287,7 @@ impl AdminApiImpl {
prepared_outbox_fanout.publish_after_outbox_commit();
self.realtime_lifecycle
.disconnect_room(&rid, "room_batch_deleted")
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Deleted)
.await;
Ok::<(), ApiError>(())

@ -118,7 +118,10 @@ impl AdminApiImpl {
prepared_fanout.publish_after_outbox_commit();
self.realtime_lifecycle
.disconnect_room(&room_id, "room_owner_inactive")
.disconnect_room(
&room_id,
synctv_realtime::sync::RoomDisconnectReason::OwnerInactive,
)
.await;
}

@ -346,7 +346,7 @@ impl AdminApiImpl {
// Force disconnect all connections and publishers in the deleted room.
self.realtime_lifecycle
.disconnect_room(&rid, "room_deleted")
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Deleted)
.await;
// Audit log: delete_room is a critical operation (best-effort)
@ -1114,7 +1114,7 @@ impl AdminApiImpl {
prepared_outbox_fanout.publish_after_outbox_commit();
self.realtime_lifecycle
.disconnect_room(&rid, "room_banned")
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Banned)
.await;
// Audit log: ban_room is a critical operation (best-effort)

@ -4004,7 +4004,11 @@ async fn test_ban_user_disconnects_owned_room_connections() -> TestResult {
.map_err(|error| test_error(format!("disconnect signal timeout: {error}")))?;
let signal = signal.map_err(|error| test_error(format!("disconnect channel: {error}")))?;
if let synctv_realtime::sync::DisconnectSignal::Room(room_id) = signal {
if let synctv_realtime::sync::DisconnectSignal::Room {
room_id,
reason: synctv_realtime::sync::RoomDisconnectReason::OwnerInactive,
} = signal
{
assert_eq!(room_id, room.id, "owned room must be disconnected");
saw_room_disconnect = true;
break;

@ -1700,7 +1700,7 @@ impl ClientApiImpl {
// Force disconnect room members and any active publishers tied to this room.
self.realtime_lifecycle
.disconnect_room(&rid, "room_deleted")
.disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Deleted)
.await;
Ok(synctv_proto::client::DeleteRoomResponse { success: true })

@ -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(

@ -1,5 +1,46 @@
use super::notifications::system_notification_server_message;
use synctv_proto::client::ServerMessage;
use synctv_proto::client::{RealtimeTerminationCode, ServerMessage};
use synctv_realtime::sync::RoomDisconnectReason;
/// Build a terminal realtime message that is delivered before the transport
/// is closed. The dedicated code is stable for client-side classification.
pub(super) fn realtime_termination_server_message(
message: impl Into<String>,
code: RealtimeTerminationCode,
) -> ServerMessage {
ServerMessage {
message: Some(synctv_proto::client::server_message::Message::Termination(
synctv_proto::client::RealtimeTermination {
message: message.into(),
code: code as i32,
},
)),
}
}
pub(super) fn room_disconnect_termination_server_message(
reason: RoomDisconnectReason,
) -> ServerMessage {
let (message, code) = match reason {
RoomDisconnectReason::AccessRevoked => (
"This room is no longer available",
RealtimeTerminationCode::RoomAccessRevoked,
),
RoomDisconnectReason::Deleted => (
"Room has been deleted",
RealtimeTerminationCode::RoomDeleted,
),
RoomDisconnectReason::Banned => {
("Room has been banned", RealtimeTerminationCode::RoomBanned)
}
RoomDisconnectReason::OwnerInactive => (
"Room is unavailable because its creator is not active",
RealtimeTerminationCode::RoomOwnerInactive,
),
};
realtime_termination_server_message(message, code)
}
/// Convert a realtime event into one or more server messages.
pub(super) fn realtime_event_to_server_messages(
@ -7,8 +48,6 @@ pub(super) fn realtime_event_to_server_messages(
_room_id: &str,
_public_id_codec: &synctv_adapter::PublicIdCodec,
) -> Result<Vec<ServerMessage>, String> {
use synctv_proto::client::server_message::Message;
use synctv_proto::client::{ErrorMessage, ServerMessage};
use synctv_realtime::sync::RealtimeEvent;
let messages = match event {
@ -19,35 +58,19 @@ pub(super) fn realtime_event_to_server_messages(
*timestamp,
)?],
RealtimeEvent::RoomDeleted { .. } => {
// Notify WebSocket clients that the room has been deleted
vec![ServerMessage {
message: Some(Message::Error(ErrorMessage {
message: "Room has been deleted".to_string(),
code: crate::impls::error_codes::NOT_FOUND,
detail: String::new(),
client_operation_id: String::new(),
})),
}]
vec![room_disconnect_termination_server_message(
RoomDisconnectReason::Deleted,
)]
}
RealtimeEvent::RoomBanned { .. } => {
vec![ServerMessage {
message: Some(Message::Error(ErrorMessage {
message: "Room has been banned".to_string(),
code: crate::impls::error_codes::FORBIDDEN,
detail: String::new(),
client_operation_id: String::new(),
})),
}]
vec![room_disconnect_termination_server_message(
RoomDisconnectReason::Banned,
)]
}
RealtimeEvent::RoomOwnerInactive { .. } => {
vec![ServerMessage {
message: Some(Message::Error(ErrorMessage {
message: "Room is unavailable because its creator is not active".to_string(),
code: crate::impls::error_codes::FORBIDDEN,
detail: String::new(),
client_operation_id: String::new(),
})),
}]
vec![room_disconnect_termination_server_message(
RoomDisconnectReason::OwnerInactive,
)]
}
RealtimeEvent::KickPublisher { .. }
| RealtimeEvent::KickUser { .. }

@ -27,7 +27,7 @@ pub fn disconnect_signal_requires_skip_cleanup(
// A global user disconnect (ban/delete) must still let cleanup emit a
// room-scoped UserLeft for the connection's current room.
DisconnectSignal::User(_uid) => false,
DisconnectSignal::Room(rid) => rid == room_id,
DisconnectSignal::Room { room_id: rid, .. } => rid == room_id,
DisconnectSignal::UserFromRoom {
user_id: uid,
room_id: rid,
@ -71,7 +71,7 @@ pub fn watch_disconnect_signal_matches(
match signal {
DisconnectSignal::Connection(conn_id) => conn_id == connection_id,
DisconnectSignal::User(uid) => uid == user_id,
DisconnectSignal::Room(rid) => rid == room_id,
DisconnectSignal::Room { room_id: rid, .. } => rid == room_id,
DisconnectSignal::UserFromRoom {
user_id: uid,
room_id: rid,

@ -1,4 +1,7 @@
use super::event_messages::realtime_event_to_server_messages;
use super::event_messages::{
realtime_event_to_server_messages, realtime_termination_server_message,
room_disconnect_termination_server_message,
};
use super::*;
use std::collections::VecDeque;
use std::future::Future;
@ -33,7 +36,7 @@ use synctv_realtime::fanout::{
};
use synctv_realtime::sync::{
ConnectionId, ConnectionLimits, ConnectionManager, RealtimeConfig, RealtimeManager,
SharedRealtimeEvent,
RoomDisconnectReason, SharedRealtimeEvent,
};
use synctv_realtime::sync::{NotificationLevel, RealtimeEvent, RoomMessageHub, WebRTCSignalKind};
use tokio::sync::{broadcast, mpsc};
@ -2711,6 +2714,121 @@ async fn test_start_cancels_and_cleans_up_when_admin_notification_send_fails() {
fixture.shutdown().await;
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn test_start_sends_termination_before_user_kick_disconnect() {
let sender = RecordingMessageSender::new();
let fixture = create_start_handler_fixture("start_user_kick_termination", sender.clone()).await;
let StartTestFixture {
handler,
connection_service,
event_service,
..
} = &fixture;
let (_tx, cancel_token) = handler.start().await.checked("start should return");
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if realtime_manager_subscriber_count(event_service, &handler.room_id) == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.checked("subscription should be established");
event_service.broadcast(RealtimeEvent::KickUser {
event_id: "evt-user-banned".to_string(),
user_id: handler.user_id,
reason: "user_banned".to_string(),
timestamp: now(),
});
wait_for_start_cleanup(
handler,
connection_service,
event_service,
&cancel_token,
true,
)
.await;
let termination = sender
.sent_messages()
.into_iter()
.find_map(|message| match message.message {
Some(Message::Termination(termination)) => Some(termination),
_ => None,
})
.checked("kick should send a realtime termination before cancellation");
assert_eq!(
termination.code,
synctv_proto::client::RealtimeTerminationCode::UserAccessRevoked as i32
);
fixture.shutdown().await;
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn test_start_sends_one_specific_termination_when_room_shutdown_paths_race() {
let sender = RecordingMessageSender::new();
let fixture =
create_start_handler_fixture("start_room_shutdown_termination", sender.clone()).await;
let StartTestFixture {
handler,
connection_service,
event_service,
..
} = &fixture;
let (_tx, cancel_token) = handler.start().await.checked("start should return");
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if realtime_manager_subscriber_count(event_service, &handler.room_id) == 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.checked("subscription should be established");
event_service.broadcast(RealtimeEvent::RoomDeleted {
event_id: "evt-room-deleted".to_string(),
room_id: handler.room_id,
deleted_by: handler.user_id,
timestamp: now(),
});
connection_service.disconnect_room(&handler.room_id, RoomDisconnectReason::Deleted);
wait_for_start_cleanup(
handler,
connection_service,
event_service,
&cancel_token,
true,
)
.await;
let terminations = sender
.sent_messages()
.into_iter()
.filter_map(|message| match message.message {
Some(Message::Termination(termination)) => Some(termination),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(terminations.len(), 1);
assert_eq!(
terminations[0].code,
synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
);
fixture.shutdown().await;
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn test_handle_client_message_sends_millisecond_heartbeat_ack() {
@ -7372,12 +7490,15 @@ fn test_room_deleted_event_conversion() {
.checked("realtime event should convert");
assert_eq!(msgs.len(), 1);
match &msgs[0].message {
Some(Message::Error(e)) => {
assert!(e.message.contains("deleted"));
assert_eq!(e.code, crate::impls::error_codes::NOT_FOUND);
Some(Message::Termination(termination)) => {
assert!(termination.message.contains("deleted"));
assert_eq!(
termination.code,
synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
);
}
other => std::panic::panic_any(format!(
"Expected Error message for RoomDeleted, got: {other:?}"
"Expected Termination message for RoomDeleted, got: {other:?}"
)),
}
}
@ -7395,12 +7516,15 @@ fn test_room_banned_event_conversion() {
.checked("realtime event should convert");
assert_eq!(msgs.len(), 1);
match &msgs[0].message {
Some(Message::Error(e)) => {
assert!(e.message.contains("banned"));
assert_eq!(e.code, crate::impls::error_codes::FORBIDDEN);
Some(Message::Termination(termination)) => {
assert!(termination.message.contains("banned"));
assert_eq!(
termination.code,
synctv_proto::client::RealtimeTerminationCode::RoomBanned as i32
);
}
other => std::panic::panic_any(format!(
"Expected Error message for RoomBanned, got: {other:?}"
"Expected Termination message for RoomBanned, got: {other:?}"
)),
}
}
@ -7419,16 +7543,73 @@ fn test_room_owner_inactive_event_conversion() {
.checked("realtime event should convert");
assert_eq!(msgs.len(), 1);
match &msgs[0].message {
Some(Message::Error(e)) => {
assert!(e.message.contains("creator"));
assert_eq!(e.code, crate::impls::error_codes::FORBIDDEN);
Some(Message::Termination(termination)) => {
assert!(termination.message.contains("creator"));
assert_eq!(
termination.code,
synctv_proto::client::RealtimeTerminationCode::RoomOwnerInactive as i32
);
}
other => std::panic::panic_any(format!(
"Expected Error message for RoomOwnerInactive, got: {other:?}"
"Expected Termination message for RoomOwnerInactive, got: {other:?}"
)),
}
}
#[test]
fn test_realtime_termination_uses_dedicated_typed_code() {
let message = realtime_termination_server_message(
"Account access revoked",
synctv_proto::client::RealtimeTerminationCode::UserAccessRevoked,
);
match message.message {
Some(Message::Termination(termination)) => {
assert_eq!(
termination.code,
synctv_proto::client::RealtimeTerminationCode::UserAccessRevoked as i32
);
}
other => std::panic::panic_any(format!(
"Expected realtime termination message, got: {other:?}"
)),
}
}
#[test]
fn test_room_disconnect_reasons_use_specific_termination_codes() {
let cases = [
(
RoomDisconnectReason::AccessRevoked,
synctv_proto::client::RealtimeTerminationCode::RoomAccessRevoked,
),
(
RoomDisconnectReason::Deleted,
synctv_proto::client::RealtimeTerminationCode::RoomDeleted,
),
(
RoomDisconnectReason::Banned,
synctv_proto::client::RealtimeTerminationCode::RoomBanned,
),
(
RoomDisconnectReason::OwnerInactive,
synctv_proto::client::RealtimeTerminationCode::RoomOwnerInactive,
),
];
for (reason, expected_code) in cases {
let message = room_disconnect_termination_server_message(reason);
match message.message {
Some(Message::Termination(termination)) => {
assert_eq!(termination.code, expected_code as i32);
}
other => std::panic::panic_any(format!(
"Expected room disconnect termination message, got: {other:?}"
)),
}
}
}
#[test]
fn test_system_notification_event_conversion() {
let event = RealtimeEvent::SystemNotification {
@ -9421,7 +9602,10 @@ fn test_disconnect_signal_requires_skip_cleanup_only_for_room_scoped_or_redundan
connection_id,
));
assert!(super::disconnect_signal_requires_skip_cleanup(
&synctv_realtime::sync::DisconnectSignal::Room(rid),
&synctv_realtime::sync::DisconnectSignal::Room {
room_id: rid,
reason: synctv_realtime::sync::RoomDisconnectReason::AccessRevoked,
},
&uid,
&rid,
connection_id,
@ -9511,7 +9695,10 @@ fn test_watch_disconnect_signal_matches_revocation_targets() {
connection_id,
));
assert!(super::watch_disconnect_signal_matches(
&synctv_realtime::sync::DisconnectSignal::Room(rid),
&synctv_realtime::sync::DisconnectSignal::Room {
room_id: rid,
reason: synctv_realtime::sync::RoomDisconnectReason::AccessRevoked,
},
&uid,
&rid,
connection_id,

@ -1277,7 +1277,7 @@ fn disconnect_applies_to_live_stream(
) -> bool {
match event {
synctv_realtime::sync::DisconnectSignal::User(uid) => uid == user_id,
synctv_realtime::sync::DisconnectSignal::Room(rid) => rid == room_id,
synctv_realtime::sync::DisconnectSignal::Room { room_id: rid, .. } => rid == room_id,
synctv_realtime::sync::DisconnectSignal::UserFromRoom {
user_id: uid,
room_id: rid,

@ -10,7 +10,7 @@ use synctv_core::service::{UserDeletedChatMessage, UserDeletionSummary};
use synctv_livestream::LiveStreamingInfrastructure;
use synctv_livestream::StreamError;
use synctv_realtime::fanout::RealtimeFanoutService;
use synctv_realtime::sync::{PublishRequest, RealtimeEvent};
use synctv_realtime::sync::{PublishRequest, RealtimeEvent, RoomDisconnectReason};
use synctv_realtime::sync::ConnectionRuntime;
@ -36,7 +36,7 @@ pub trait RealtimeLifecycleService: Send + Sync {
async fn active_room_stream_media_ids(&self, room_id: &RoomId) -> Vec<MediaId>;
async fn disconnect_room(&self, room_id: &RoomId, publisher_reason: &str);
async fn disconnect_room(&self, room_id: &RoomId, reason: RoomDisconnectReason);
async fn disconnect_user_from_room(&self, room_id: &RoomId, user_id: &UserId);
@ -268,8 +268,8 @@ impl RealtimeLifecycleService for DefaultRealtimeLifecycleService {
media_ids.into_iter().collect()
}
async fn disconnect_room(&self, room_id: &RoomId, _publisher_reason: &str) {
self.connection_service.disconnect_room(room_id);
async fn disconnect_room(&self, room_id: &RoomId, reason: RoomDisconnectReason) {
self.connection_service.disconnect_room(room_id, reason);
let room_id_key = room_id.to_string();
if let Some(infra) = &self.live_streaming_infrastructure {
@ -425,7 +425,8 @@ impl RealtimeLifecycleService for DefaultRealtimeLifecycleService {
self.realtime_fanout
.publish_after_outbox_commit(deleted_room.event);
self.disconnect_room(&room_id, "room_deleted").await;
self.disconnect_room(&room_id, RoomDisconnectReason::Deleted)
.await;
}
self.disconnect_user(&summary.user_id, disconnect_reason)

@ -916,6 +916,7 @@ const fn is_critical_message(message: &ServerMessage) -> bool {
&message.message,
Some(
Message::Error(_)
| Message::Termination(_)
| Message::ResourceObserved(_)
| Message::ResourceEvent(_)
| Message::ResourceObserveError(_)
@ -934,6 +935,7 @@ const fn message_type_name(message: &ServerMessage) -> &'static str {
match &message.message {
Some(Message::HeartbeatAck(_)) => "HeartbeatAck",
Some(Message::Error(_)) => "Error",
Some(Message::Termination(_)) => "Termination",
Some(Message::ResourceObserved(_)) => "ResourceObserved",
Some(Message::ResourceEvent(_)) => "ResourceEvent",
Some(Message::ResourceObserveError(_)) => "ResourceObserveError",

@ -229,6 +229,21 @@ fn test_resource_event_is_critical() {
assert_eq!(message_type_name(&message), "ResourceEvent");
}
#[test]
fn test_realtime_termination_is_critical() {
let message = ServerMessage {
message: Some(synctv_proto::client::server_message::Message::Termination(
synctv_proto::client::RealtimeTermination {
message: "Account access revoked".to_string(),
code: synctv_proto::client::RealtimeTerminationCode::UserAccessRevoked as i32,
},
)),
};
assert!(is_critical_message(&message));
assert_eq!(message_type_name(&message), "Termination");
}
#[test]
fn test_websocket_json_uses_integer_enum_values() -> TestResult {
let message = ServerMessage {
@ -1352,7 +1367,7 @@ fn test_critical_messages_bypass_full_normal_queue() -> TestResult {
let result = sender.send(ServerMessage {
message: Some(Message::Error(ErrorMessage {
message: "critical".to_string(),
code: synctv_proto::common::ErrorCode::Forbidden as i32,
code: synctv_api_common::impls::error_codes::FORBIDDEN,
detail: String::new(),
client_operation_id: String::new(),
})),

@ -571,6 +571,7 @@ mod websocket_e2e {
pub(super) room_service: Arc<RoomService>,
pub(super) user_service: Arc<UserService>,
pub(super) connection_manager: Arc<ConnectionManager>,
client_api: Arc<synctv_api::ClientApiImpl>,
realtime_manager: Arc<RealtimeManager>,
ws_ticket_service: Arc<dyn synctv_core::service::WebSocketTicketService>,
server_shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
@ -1176,7 +1177,7 @@ mod websocket_e2e {
public_id_codec,
request_executor,
metrics_access_controller: Arc::new(synctv_api::MetricsAccessController::new()),
client_api,
client_api: client_api.clone(),
admin_api: None,
email_api: None,
notification_api: None,
@ -1289,6 +1290,7 @@ mod websocket_e2e {
room_service,
user_service,
connection_manager: connection_manager_ret,
client_api,
realtime_manager,
ws_ticket_service,
server_shutdown_tx: Some(shutdown_tx),
@ -2777,6 +2779,61 @@ mod websocket_e2e {
.expect("close replica-2 user");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_ws_delete_room_api_sends_one_specific_termination_before_close() {
let infra = TestInfra::new().await;
let mut server = setup_e2e_server(&infra).await;
let (owner_id, owner_token) = register_test_user(
&server.user_service,
&server.jwt_service,
"delete_room_termination_owner",
)
.await;
let room_id =
create_test_room(&server.room_service, &owner_id, "Delete Termination Room").await;
let mut ws_owner = ws_connect(&server.addr, &room_id, &owner_token).await;
drain_until_quiet(&mut ws_owner, 1500).await;
server
.client_api
.delete_room(&owner_id, &room_id)
.await
.expect("delete room through client API");
let termination = recv_matching_server_message(
&mut ws_owner,
std::time::Duration::from_secs(10),
|message| {
matches!(
message.message,
Some(server_message::Message::Termination(_))
)
},
"room deletion termination",
)
.await;
assert!(matches!(
termination.message,
Some(server_message::Message::Termination(termination))
if termination.code
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
));
assert!(
tokio::time::timeout(
std::time::Duration::from_secs(10),
recv_server_message(&mut ws_owner),
)
.await
.expect("room-deleted stream termination should complete")
.is_none(),
"room deletion should close the stream after its sole termination message"
);
server.shutdown().await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_ws_cross_replica_room_realtime_message_matrix_via_realtime_events() {
@ -2878,23 +2935,35 @@ mod websocket_e2e {
|message| {
matches!(
&message.message,
Some(server_message::Message::Error(error))
if error.message.contains("deleted")
Some(server_message::Message::Termination(termination))
if termination.code
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
)
},
"cross-replica room deleted error",
"cross-replica room deleted termination",
)
.await;
assert!(
matches!(
room_deleted_msg.message,
Some(server_message::Message::Error(_))
Some(server_message::Message::Termination(termination))
if termination.code
== synctv_proto::client::RealtimeTerminationCode::RoomDeleted as i32
),
"RoomDeleted event should be forwarded as terminal error"
"RoomDeleted should terminate the realtime stream with a dedicated code"
);
assert!(
tokio::time::timeout(
std::time::Duration::from_secs(10),
recv_server_message(&mut ws_member),
)
.await
.expect("room-deleted stream termination should complete")
.is_none(),
"RoomDeleted should close the realtime stream after the termination message"
);
ws_owner.close(None).await.expect("close owner");
ws_member.close(None).await.expect("close member");
}
#[tokio::test]

@ -427,6 +427,9 @@ impl RoomAdminPermissionBits {
if bits & Self::DELETE_ROOM != 0 {
permissions |= RoomAdminPermissionBits::DELETE_ROOM;
}
if bits & Self::VIEW_PLAYBACK_HISTORY != 0 {
permissions |= RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY;
}
if bits & Self::USE_P2P_MEDIA != 0 {
permissions |= RoomAdminPermissionBits::USE_P2P_MEDIA;
}
@ -493,6 +496,9 @@ impl RoomAdminPermissionBits {
if permissions & RoomAdminPermissionBits::DELETE_ROOM != 0 {
bits |= Self::DELETE_ROOM;
}
if permissions & RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY != 0 {
bits |= Self::VIEW_PLAYBACK_HISTORY;
}
if permissions & RoomAdminPermissionBits::USE_P2P_MEDIA != 0 {
bits |= Self::USE_P2P_MEDIA;
}
@ -831,6 +837,30 @@ mod tests {
assert!(!guest_perms.has(crate::models::RoomPermission::MANAGE_OWN_MEDIA));
}
#[test]
fn admin_playback_history_permission_mapping_is_bidirectional() {
assert_eq!(
RoomAdminPermissionBits::to_permissions(RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY),
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
);
assert_eq!(
RoomAdminPermissionBits::from_permissions(
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
),
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
);
let mut member = crate::models::RoomMember::new(
crate::models::RoomId::expect_positive(1),
crate::models::UserId::expect_positive(1),
Role::Admin,
);
member.admin_added_permissions = RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY;
assert!(member
.effective_permissions(RoomPermissionSet::empty())
.has(crate::models::RoomPermission::VIEW_PLAYBACK_HISTORY));
}
#[test]
fn test_allow_deny_pattern() {
let mut perms = RoomPermissionSet::default_member();

@ -143,6 +143,10 @@ const NAMED_PERMISSIONS: &[(&str, u64)] = &[
"delete_chat_messages",
RoomAdminPermissionBits::DELETE_CHAT_MESSAGES,
),
(
"view_playback_history",
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY,
),
("delete_room", RoomAdminPermissionBits::DELETE_ROOM),
];
@ -192,15 +196,30 @@ mod tests {
#[test]
fn permission_set_accepts_exact_canonical_names() {
let permissions = PermissionSet::from_str(r#"["manage_live_streams","view_members"]"#)
.expect("canonical permission names should parse");
let permissions = PermissionSet::from_str(
r#"["manage_live_streams","view_members","view_playback_history"]"#,
)
.expect("canonical permission names should parse");
assert_eq!(
permissions.bits().0,
RoomAdminPermissionBits::MANAGE_LIVE_STREAMS | RoomAdminPermissionBits::VIEW_MEMBERS
RoomAdminPermissionBits::MANAGE_LIVE_STREAMS
| RoomAdminPermissionBits::VIEW_MEMBERS
| RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
);
}
#[test]
fn permission_set_preserves_playback_history_in_display_round_trip() {
let original = PermissionSet::from_bits(crate::models::RoomPermissionSet(
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY,
));
let restored = PermissionSet::from_str(&original.to_string())
.expect("displayed permission names should parse");
assert_eq!(restored, original);
}
#[test]
fn voice_participant_limit_accepts_mesh_operating_range() {
for max_voice_participants_per_room in [2, 8, 32] {

@ -3346,9 +3346,32 @@ message ServerMessage {
ResourceObserved resource_observed = 29;
ResourceEvent resource_event = 30;
ResourceObserveError resource_observe_error = 31;
// Terminal realtime state change sent immediately before the stream closes.
// Clients can classify an intentional server-side disconnect without
// relying on the transport close frame or a localized error string.
RealtimeTermination termination = 32;
}
}
message RealtimeTermination {
// User-facing explanation of why the realtime stream is ending.
string message = 1;
// Stable machine-readable realtime termination category.
RealtimeTerminationCode code = 2 [(buf.validate.field).enum.defined_only = true];
}
enum RealtimeTerminationCode {
REALTIME_TERMINATION_CODE_UNSPECIFIED = 0;
REALTIME_TERMINATION_CODE_CONNECTION_REVOKED = 1;
REALTIME_TERMINATION_CODE_USER_ACCESS_REVOKED = 2;
REALTIME_TERMINATION_CODE_ROOM_ACCESS_REVOKED = 3;
REALTIME_TERMINATION_CODE_ROOM_MEMBERSHIP_REVOKED = 4;
REALTIME_TERMINATION_CODE_GUEST_ACCESS_REVOKED = 5;
REALTIME_TERMINATION_CODE_ROOM_DELETED = 6;
REALTIME_TERMINATION_CODE_ROOM_BANNED = 7;
REALTIME_TERMINATION_CODE_ROOM_OWNER_INACTIVE = 8;
}
message ResourceObserved {
string observe_id = 1;
bool changed = 3;

@ -54,17 +54,6 @@ enum ListSortDirection {
LIST_SORT_DIRECTION_DESC = 2;
}
// Error codes for ErrorMessage
enum ErrorCode {
ERROR_CODE_UNSPECIFIED = 0;
ERROR_CODE_UNAUTHORIZED = 1;
ERROR_CODE_FORBIDDEN = 2;
ERROR_CODE_NOT_FOUND = 3;
ERROR_CODE_RATE_LIMITED = 4;
ERROR_CODE_VALIDATION_FAILED = 5;
ERROR_CODE_INTERNAL = 6;
}
// ==================== Shared Types ====================
// Room member information (shared between admin and client APIs)

@ -46,11 +46,23 @@ pub enum DisconnectSignal {
/// Disconnect all connections for a user
User(UserId),
/// Disconnect all connections in a room
Room(RoomId),
Room {
room_id: RoomId,
reason: RoomDisconnectReason,
},
/// Disconnect a specific user from a specific room
UserFromRoom { user_id: UserId, room_id: RoomId },
}
/// Stable internal classification for room-wide forced disconnects.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoomDisconnectReason {
AccessRevoked,
Deleted,
Banned,
OwnerInactive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceRtcJoinOutcome {
Joined,

@ -4,7 +4,7 @@ use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use super::metrics::{ShutdownReport, ShutdownTaskOutcome};
use super::{ConnectionManager, DisconnectSignal};
use super::{ConnectionManager, DisconnectSignal, RoomDisconnectReason};
use synctv_core::models::id::{RoomId, UserId};
impl ConnectionManager {
@ -149,14 +149,18 @@ impl ConnectionManager {
self.send_disconnect_signal(&signal);
}
pub fn disconnect_room(&self, room_id: &RoomId) {
pub fn disconnect_room(&self, room_id: &RoomId, reason: RoomDisconnectReason) {
let conn_count = self.room_connection_count(room_id);
info!(
room_id = %room_id,
?reason,
connection_count = conn_count,
"Forcing disconnect of all room connections"
);
let signal = DisconnectSignal::Room(*room_id);
let signal = DisconnectSignal::Room {
room_id: *room_id,
reason,
};
self.send_disconnect_signal(&signal);
}

@ -16,7 +16,7 @@ use synctv_core::RedisCoordinationRuntime;
pub use backpressure::{BufferPressure, PublishBackpressure};
pub use connection_manager::{
ConnectionInfo, ConnectionLimits, ConnectionLimitsOptions, ConnectionManager,
ConnectionMetrics, DisconnectSignal, VoiceRtcJoinOutcome,
ConnectionMetrics, DisconnectSignal, RoomDisconnectReason, VoiceRtcJoinOutcome,
};
pub use dedup::{DedupKey, MessageDeduplicator};
pub use realtime_manager::{

@ -7,7 +7,7 @@ use tokio_util::sync::CancellationToken;
use super::connection_manager::{
ConnectionInfo, ConnectionLimits, ConnectionManager, ConnectionMetrics, DisconnectSignal,
ShutdownReport, VoiceRtcJoinOutcome,
RoomDisconnectReason, ShutdownReport, VoiceRtcJoinOutcome,
};
use super::room_hub::{ConnectionId, RoomLifecycleEvent, RoomMessageHub};
use super::{RealtimeEvent, SharedRealtimeEvent};
@ -175,7 +175,7 @@ pub trait ConnectionRuntime: Send + Sync {
fn disconnect_user(&self, user_id: &UserId);
fn disconnect_room(&self, room_id: &RoomId);
fn disconnect_room(&self, room_id: &RoomId, reason: RoomDisconnectReason);
fn disconnect_user_from_room(&self, user_id: &UserId, room_id: &RoomId);
@ -312,8 +312,8 @@ impl ConnectionRuntime for ConnectionManager {
ConnectionManager::disconnect_user(self, user_id);
}
fn disconnect_room(&self, room_id: &RoomId) {
ConnectionManager::disconnect_room(self, room_id);
fn disconnect_room(&self, room_id: &RoomId, reason: RoomDisconnectReason) {
ConnectionManager::disconnect_room(self, room_id, reason);
}
fn disconnect_user_from_room(&self, user_id: &UserId, room_id: &RoomId) {

@ -226,7 +226,7 @@ async fn test_total_connection_limit() {
#[tokio::test]
async fn test_disconnect_signals() {
use synctv_realtime::sync::DisconnectSignal;
use synctv_realtime::sync::{DisconnectSignal, RoomDisconnectReason};
let mgr = ConnectionManager::default();
let user = uid("u1");
@ -255,10 +255,16 @@ async fn test_disconnect_signals() {
);
// Room disconnect
mgr.disconnect_room(&room);
mgr.disconnect_room(&room, RoomDisconnectReason::AccessRevoked);
let sig = rx.recv().await.unwrap();
assert!(
matches!(sig, DisconnectSignal::Room(ref id) if id == &room),
matches!(
sig,
DisconnectSignal::Room {
room_id,
reason: RoomDisconnectReason::AccessRevoked,
} if room_id == room
),
"Expected Room signal"
);
}

@ -8,7 +8,7 @@ use std::time::Duration;
use synctv_core::models::id::{RoomId, UserId};
use synctv_realtime::sync::ConnectionManager;
use synctv_realtime::sync::{ConnectionLimits, DisconnectSignal};
use synctv_realtime::sync::{ConnectionLimits, DisconnectSignal, RoomDisconnectReason};
fn stable_test_id(s: &str) -> i64 {
s.bytes().fold(0_i64, |acc, byte| {
@ -177,11 +177,17 @@ async fn test_disconnect_signal_to_room() {
let mut rx = mgr.subscribe_disconnect();
// Disconnect entire room
mgr.disconnect_room(&room);
mgr.disconnect_room(&room, RoomDisconnectReason::Deleted);
// Should receive signal
let signal = rx.recv().await.expect("Should receive disconnect signal");
assert!(matches!(signal, DisconnectSignal::Room(ref r) if r == &room));
assert!(matches!(
signal,
DisconnectSignal::Room {
room_id,
reason: RoomDisconnectReason::Deleted,
} if room_id == room
));
}
#[tokio::test]

Loading…
Cancel
Save