fix: Chats doesn't sync for multi client users
This commit is contained in:
@@ -121,6 +121,8 @@ Each signaling URL gets its own `SignalingManager` (one WebSocket each). `Signal
|
||||
|
||||
**Identify-before-join invariant.** The server drops any `join_server` / `view_server` that arrives on a connection that has not yet `identify`-ed, so a join that races ahead of identify is silently lost and the user never appears in the presence roster. On every (re)connect, `SignalingManager.reIdentifyAndRejoin` therefore re-`identify`s and only then re-joins. For this to work the manager's credential lookup (`SignalingTransportHandler.getIdentifyCredentialsForSignalUrl`) must resolve a credential as soon as one exists for that signal URL — it falls back to the credential store when the per-URL identify cache has not been populated yet. Do not narrow that lookup to only the in-memory cache; doing so lets a fresh socket emit a join before any identify and reintroduces the dropped-presence bug.
|
||||
|
||||
The server side enforces this ordering too: `handleWebSocketMessage` serializes messages per connection (a promise chain keyed by connection id in `server/src/websocket/handler.ts`). `identify` awaits a session-token DB lookup, and `identify` + `join_server` sent back-to-back often arrive in the same TCP segment; without serialization the join was evaluated mid-identify, rejected with `auth_required`, and the room membership was silently lost — the client then never received `user_joined` / `chat_message` broadcasts for that room even though same-account `account_sync` kept working, which is exactly the "chats don't sync for multi-client users" failure mode. Do not process websocket messages for one connection concurrently.
|
||||
|
||||
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
|
||||
|
||||
Server-relayed fallbacks are intentionally narrow. Room chat (`chat_message`), direct-message events (`direct-message`, `direct-message-status`, `direct-message-mutation`), and voice presence (`voice_state`) may flow over signaling so users can still see written chat and voice roster state while P2P data channels are down. Media, attachments, message inventory sync, screen/camera state, and plugin data-channel traffic remain peer-plane responsibilities.
|
||||
@@ -170,7 +172,7 @@ Browsers do not reliably fire WebSocket close events during page refresh or navi
|
||||
|
||||
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values per tab/device). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
|
||||
|
||||
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot.
|
||||
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot including chunked `chat-sync-batch` history for every saved room.
|
||||
|
||||
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { pushSavedRoomMessagesViaAccountSync } from './account-sync-chat.helper';
|
||||
import type { Message } from '../../../shared-kernel';
|
||||
|
||||
function createMessage(id: string, roomId: string): Message {
|
||||
return {
|
||||
id,
|
||||
roomId,
|
||||
senderId: 'user-1',
|
||||
senderName: 'User 1',
|
||||
content: `message-${id}`,
|
||||
timestamp: 1,
|
||||
reactions: [],
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
describe('pushSavedRoomMessagesViaAccountSync', () => {
|
||||
it('relays saved room messages in chat-sync-batch chunks to sibling devices', async () => {
|
||||
const relayAccountSync = vi.fn();
|
||||
const roomA = [createMessage('m1', 'room-a'), createMessage('m2', 'room-a')];
|
||||
const roomB = [createMessage('m3', 'room-b')];
|
||||
const loadRoomMessages = vi.fn(async (roomId: string) => {
|
||||
if (roomId === 'room-a') {
|
||||
return roomA;
|
||||
}
|
||||
|
||||
if (roomId === 'room-b') {
|
||||
return roomB;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
{ relayAccountSync },
|
||||
loadRoomMessages,
|
||||
['room-a', 'room-b']
|
||||
);
|
||||
|
||||
expect(loadRoomMessages).toHaveBeenCalledWith('room-a');
|
||||
expect(loadRoomMessages).toHaveBeenCalledWith('room-b');
|
||||
expect(relayAccountSync).toHaveBeenCalledWith({
|
||||
type: 'chat-sync-batch',
|
||||
roomId: 'room-a',
|
||||
messages: roomA
|
||||
});
|
||||
|
||||
expect(relayAccountSync).toHaveBeenCalledWith({
|
||||
type: 'chat-sync-batch',
|
||||
roomId: 'room-b',
|
||||
messages: roomB
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Message } from '../../../shared-kernel';
|
||||
import type { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
FULL_SYNC_LIMIT,
|
||||
chunkArray
|
||||
} from '../../../store/messages/messages.helpers';
|
||||
|
||||
export async function pushSavedRoomMessagesViaAccountSync(
|
||||
webrtc: Pick<RealtimeSessionFacade, 'relayAccountSync'>,
|
||||
loadRoomMessages: (roomId: string) => Promise<Message[]>,
|
||||
roomIds: readonly string[]
|
||||
): Promise<void> {
|
||||
for (const roomId of roomIds) {
|
||||
const messages = await loadRoomMessages(roomId);
|
||||
|
||||
for (const chunk of chunkArray(messages, CHUNK_SIZE)) {
|
||||
webrtc.relayAccountSync({
|
||||
type: 'chat-sync-batch',
|
||||
roomId,
|
||||
messages: chunk
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ACCOUNT_SYNC_MESSAGE_LIMIT = FULL_SYNC_LIMIT;
|
||||
@@ -20,6 +20,7 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
|
||||
import { CustomEmojiService } from '../../../domains/custom-emoji/application/custom-emoji.service';
|
||||
import { shouldApplyAccountSyncPayload } from './account-sync.rules';
|
||||
import { ACCOUNT_SYNC_MESSAGE_LIMIT, pushSavedRoomMessagesViaAccountSync } from './account-sync-chat.helper';
|
||||
import { pushProfileViaAccountSync } from './account-sync-profile.helper';
|
||||
import type { Room } from '../../../shared-kernel';
|
||||
import type { IncomingSignalingMessage } from '../signaling/signaling-message-handler';
|
||||
@@ -145,6 +146,12 @@ export class AccountSyncEffects {
|
||||
this.webrtc.relayAccountSync({ type: 'saved-room-sync', room });
|
||||
}
|
||||
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
this.webrtc,
|
||||
(roomId) => this.db.getMessages(roomId, ACCOUNT_SYNC_MESSAGE_LIMIT, 0),
|
||||
savedRooms.map((room) => room.id)
|
||||
);
|
||||
|
||||
const friends = await this.friends.friends();
|
||||
|
||||
for (const friend of friends) {
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
} from './account-sync.rules';
|
||||
|
||||
describe('account-sync.rules', () => {
|
||||
it('relays profile, emoji, room, and moderation events but not chat-message or voice-state', () => {
|
||||
it('relays profile, emoji, room, moderation, and chat events but not voice-state', () => {
|
||||
expect(isRelayableAccountSyncEvent({ type: 'user-avatar-summary', oderId: 'u1', avatarUpdatedAt: 1 })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'custom-emoji-full', customEmoji: {} as never })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'server-icon-update', roomId: 'r1', icon: 'x', iconUpdatedAt: 1 })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'saved-room-sync', room: { id: 'r1' } as never })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'friend-added', userId: 'u2', addedAt: 1 })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(false);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'message-revision', revision: {} as never })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'chat-sync-batch', roomId: 'r1', messages: [] })).toBe(true);
|
||||
expect(isRelayableAccountSyncEvent({ type: 'voice-state', voiceState: {} as never })).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ChatEvent } from '../../../shared-kernel';
|
||||
|
||||
const DEDICATED_SIGNALING_RELAY_TYPES = new Set([
|
||||
'chat-message',
|
||||
'voice-state'
|
||||
]);
|
||||
|
||||
const DEDICATED_SIGNALING_RELAY_TYPES = new Set(['voice-state']);
|
||||
const RELAYABLE_ACCOUNT_SYNC_TYPES = new Set([
|
||||
'chat-message',
|
||||
'message-revision',
|
||||
'chat-sync-batch',
|
||||
'user-avatar-summary',
|
||||
'user-avatar-request',
|
||||
'user-avatar-full',
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { Observable, Subject, merge } from 'rxjs';
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
merge
|
||||
} from 'rxjs';
|
||||
import { ChatEvent } from '../../shared-kernel';
|
||||
import type { SignalingMessage } from '../../shared-kernel';
|
||||
import {
|
||||
@@ -767,6 +771,15 @@ export class WebRTCService implements OnDestroy {
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
this.relayAccountSync({
|
||||
...event,
|
||||
message: {
|
||||
...event.message,
|
||||
clientInstanceId
|
||||
},
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user