fix: Chats doesn't sync for multi client users

This commit is contained in:
2026-06-11 00:04:49 +02:00
parent d0aff6319d
commit d174536272
16 changed files with 662 additions and 16 deletions

View File

@@ -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`.

View File

@@ -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
});
});
});

View File

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

View File

@@ -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) {

View File

@@ -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);
});

View File

@@ -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',

View File

@@ -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;
}