fix: harden data integrity, connection stability, and memory management
Wrap all multi-write DB operations in atomic transactions (server/channel creation, message+attachment linking, DM creation, friend acceptance, cascading deletes) to prevent partial-write corruption. Batch N+1 queries in WS ready payload into O(1) bulk fetches with chunked inArray() to respect SQLite's variable limit. Fix chat history regression where background WS messages bypassed channel load by switching the guard from messages.has() to hasMore.has(). Add LRU channel eviction (20 cached, evict to 15) and per-channel message cap (200) to bound client memory growth. Shorten WS heartbeat from 30s to 15s for aggressive proxy/NAT environments. Clear all user-scoped stores on logout to prevent cross-session data leaks. Extract LiveKit internal accessors into shared livekitInternals utility.
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
stopScreenShare,
|
||||
handleScreenShareUnpublished,
|
||||
} from '../utils/screenShare';
|
||||
import { getMediaStreamTrack } from '../utils/livekitInternals';
|
||||
|
||||
let _activeRoom: Room | null = null;
|
||||
|
||||
@@ -522,7 +523,7 @@ export function useLiveKit() {
|
||||
const opts = buildScreenShareOptions(screenShareConfig);
|
||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||
if (screenPub?.videoTrack) {
|
||||
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
|
||||
const mediaTrack = getMediaStreamTrack(screenPub.videoTrack);
|
||||
if (mediaTrack) {
|
||||
await mediaTrack.applyConstraints({ width: { ideal: opts.capture.width }, height: { ideal: opts.capture.height }, frameRate: { ideal: opts.capture.frameRate } });
|
||||
mediaTrack.contentHint = opts.contentHint;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Track } from 'livekit-client';
|
||||
import { getActiveRoom } from './useLiveKit';
|
||||
import { discoverPeerConnections } from '../utils/livekitInternals';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -96,40 +97,6 @@ function reportKind(report: any): 'audio' | 'video' | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
||||
* Different livekit-client versions expose the PC at different internal paths.
|
||||
*/
|
||||
function discoverPeerConnections(room: any): RTCPeerConnection[] {
|
||||
const engine = room?.engine;
|
||||
if (!engine) return [];
|
||||
|
||||
const pcs: RTCPeerConnection[] = [];
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const tryAdd = (val: any) => {
|
||||
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
pcs.push(val);
|
||||
}
|
||||
};
|
||||
|
||||
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
||||
tryAdd(engine.pcManager?.publisher?.pc);
|
||||
tryAdd(engine.pcManager?.subscriber?.pc);
|
||||
// Private backing field fallback
|
||||
tryAdd(engine.pcManager?.publisher?._pc);
|
||||
tryAdd(engine.pcManager?.subscriber?._pc);
|
||||
// Older livekit-client paths
|
||||
tryAdd(engine.publisher?.pc);
|
||||
tryAdd(engine.subscriber?.pc);
|
||||
// Unified-plan single PC
|
||||
tryAdd(engine.pc);
|
||||
tryAdd(room.pc);
|
||||
|
||||
return pcs;
|
||||
}
|
||||
|
||||
function inferSimulcastLayer(width: number | null, height: number | null): string | null {
|
||||
if (height !== null && height > 0) {
|
||||
if (height >= 1000) return 'High';
|
||||
|
||||
@@ -357,7 +357,7 @@ function connect(): void {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30_000);
|
||||
}, 15_000);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '@opencord/shared';
|
||||
import { api } from '../api/client';
|
||||
import { useChatStore } from './chatStore';
|
||||
import { useServerStore } from './serverStore';
|
||||
import { useSocialStore } from './socialStore';
|
||||
import { useVoiceStore } from './voiceStore';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
@@ -48,6 +52,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('opencord_token');
|
||||
// Clear all user-scoped state to prevent data leaking between sessions
|
||||
useChatStore.getState().clearAllMessages();
|
||||
useServerStore.getState().populateFromReady([], [], []);
|
||||
useSocialStore.getState().reset();
|
||||
useVoiceStore.getState().clearAllVoiceUsers();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
const MAX_MESSAGES_PER_CHANNEL = 200;
|
||||
const MAX_CACHED_CHANNELS = 20;
|
||||
const EVICT_TO_CHANNELS = 15;
|
||||
|
||||
interface TypingUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -27,6 +31,7 @@ interface ChatState {
|
||||
readStates: Map<string, string>;
|
||||
unreadChannels: Set<string>;
|
||||
realtimeMessageEvents: RealtimeMessageEvent[];
|
||||
channelAccessTimes: Map<string, number>;
|
||||
setCurrentChannel: (channelId: string | null) => void;
|
||||
setReplyTo: (message: MessageWithUser | null) => void;
|
||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||
@@ -64,14 +69,59 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
channelAccessTimes: new Map(),
|
||||
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setCurrentChannel: (channelId) => {
|
||||
set((state) => {
|
||||
const newAccessTimes = new Map(state.channelAccessTimes);
|
||||
if (channelId) {
|
||||
newAccessTimes.set(channelId, Date.now());
|
||||
}
|
||||
|
||||
// Evict stale channels if we have too many cached
|
||||
let newMessages = state.messages;
|
||||
let newHasMore = state.hasMore;
|
||||
if (state.messages.size > MAX_CACHED_CHANNELS) {
|
||||
const entries = [...newAccessTimes.entries()]
|
||||
.filter(([id]) => id !== channelId)
|
||||
.sort((a, b) => a[1] - b[1]);
|
||||
const toEvict = state.messages.size - EVICT_TO_CHANNELS;
|
||||
const evictIds = new Set(entries.slice(0, toEvict).map(([id]) => id));
|
||||
if (evictIds.size > 0) {
|
||||
newMessages = new Map(state.messages);
|
||||
newHasMore = new Map(state.hasMore);
|
||||
for (const id of evictIds) {
|
||||
newMessages.delete(id);
|
||||
newHasMore.delete(id);
|
||||
newAccessTimes.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentChannelId: channelId,
|
||||
channelAccessTimes: newAccessTimes,
|
||||
messages: newMessages,
|
||||
hasMore: newHasMore,
|
||||
};
|
||||
});
|
||||
},
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
clearAllMessages: () => set({
|
||||
messages: new Map(),
|
||||
hasMore: new Map(),
|
||||
typingUsers: new Map(),
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
channelAccessTimes: new Map(),
|
||||
currentChannelId: null,
|
||||
replyTo: null,
|
||||
}),
|
||||
|
||||
loadMessages: async (channelId: string, force?: boolean) => {
|
||||
if (!force && get().messages.has(channelId)) return;
|
||||
if (!force && get().hasMore.has(channelId)) return;
|
||||
set({ isLoading: true, loadError: null });
|
||||
try {
|
||||
const isDm = isDmChannel(channelId);
|
||||
@@ -84,7 +134,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
newMessages.set(channelId, messages as MessageWithUser[]);
|
||||
const newHasMore = new Map(state.hasMore);
|
||||
newHasMore.set(channelId, messages.length >= 50);
|
||||
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
|
||||
const newAccessTimes = new Map(state.channelAccessTimes);
|
||||
newAccessTimes.set(channelId, Date.now());
|
||||
return { messages: newMessages, hasMore: newHasMore, channelAccessTimes: newAccessTimes, isLoading: false, loadError: null };
|
||||
});
|
||||
} catch (err) {
|
||||
set({ isLoading: false, loadError: (err as Error).message || 'Failed to load messages' });
|
||||
@@ -232,7 +284,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
let updated = [...filtered, message];
|
||||
// Cap per-channel messages to prevent memory growth
|
||||
if (updated.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||
updated = updated.slice(updated.length - MAX_MESSAGES_PER_CHANNEL);
|
||||
}
|
||||
newMessages.set(channelId, updated);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
@@ -248,7 +305,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
let updated = [...filtered, message];
|
||||
// Cap per-channel messages to prevent memory growth
|
||||
if (updated.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||
updated = updated.slice(updated.length - MAX_MESSAGES_PER_CHANNEL);
|
||||
}
|
||||
newMessages.set(channelId, updated);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface SocialState {
|
||||
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
||||
updateFriendPresence: (userId: string, status: string) => void;
|
||||
removeFriendLocally: (userId: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
@@ -139,4 +140,6 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
reset: () => set({ friends: [], requests: [], isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Room } from 'livekit-client';
|
||||
|
||||
/**
|
||||
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
||||
* Different livekit-client versions expose the PC at different internal paths.
|
||||
*/
|
||||
export function discoverPeerConnections(room: Room): RTCPeerConnection[] {
|
||||
const engine = (room as any)?.engine;
|
||||
if (!engine) return [];
|
||||
|
||||
const pcs: RTCPeerConnection[] = [];
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const tryAdd = (val: any) => {
|
||||
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
pcs.push(val);
|
||||
}
|
||||
};
|
||||
|
||||
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
||||
tryAdd(engine.pcManager?.publisher?.pc);
|
||||
tryAdd(engine.pcManager?.subscriber?.pc);
|
||||
// Private backing field fallback
|
||||
tryAdd(engine.pcManager?.publisher?._pc);
|
||||
tryAdd(engine.pcManager?.subscriber?._pc);
|
||||
// Older livekit-client paths
|
||||
tryAdd(engine.publisher?.pc);
|
||||
tryAdd(engine.subscriber?.pc);
|
||||
// Unified-plan single PC
|
||||
tryAdd(engine.pc);
|
||||
tryAdd((room as any).pc);
|
||||
|
||||
return pcs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the publisher RTCPeerConnection from a LiveKit Room.
|
||||
* Used by overdrive to inject RTP sender parameters.
|
||||
*/
|
||||
export function getPublisherPC(room: Room): RTCPeerConnection | null {
|
||||
const engine = (room as any)?.engine;
|
||||
if (!engine) return null;
|
||||
|
||||
return (
|
||||
engine.pcManager?.publisher?.pc ??
|
||||
engine.pcManager?.publisher?._pc ??
|
||||
engine.publisher?.pc ??
|
||||
engine.pc ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract the underlying MediaStreamTrack from a LiveKit track object.
|
||||
* Handles both public `.mediaStreamTrack` and private `._mediaStreamTrack`.
|
||||
*/
|
||||
export function getMediaStreamTrack(track: unknown): MediaStreamTrack | null {
|
||||
if (!track) return null;
|
||||
const t = track as any;
|
||||
return t.mediaStreamTrack ?? t._mediaStreamTrack ?? null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../stores/voiceStore';
|
||||
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -87,12 +88,12 @@ export async function applyOverdrive(
|
||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||
if (!pub?.track) return;
|
||||
|
||||
const engine = (room as any).engine;
|
||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
||||
const pc = getPublisherPC(room);
|
||||
if (!pc) return;
|
||||
|
||||
const senders = (pc as RTCPeerConnection).getSenders();
|
||||
const sender = senders.find(s => s.track?.id === (pub.track as any).mediaStreamTrack?.id);
|
||||
const pubMediaTrack = getMediaStreamTrack(pub.track);
|
||||
const senders = pc.getSenders();
|
||||
const sender = senders.find(s => s.track?.id === pubMediaTrack?.id);
|
||||
if (!sender) return;
|
||||
|
||||
const params = sender.getParameters();
|
||||
|
||||
Reference in New Issue
Block a user