fix: Bug - Voice states doesn't get cleared for all users on leave

Broadcast a cleared voice_state when voice-active sockets drop and reset mute/deafen flags on disconnect or reconnect so stale session state cannot leak to other clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 01:00:01 +02:00
parent e75b4a38ed
commit 29032b5a36
8 changed files with 443 additions and 29 deletions

View File

@@ -0,0 +1,105 @@
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { finalizeVoiceDisconnectForConnection } from './handler';
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
terminate: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(
connectionId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const user: ConnectedUser = {
oderId: 'user-1',
ws: createMockWs(),
authenticated: true,
serverIds: new Set(['server-1']),
displayName: 'Alice',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getSentMessages(user: ConnectedUser): string[] {
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
}
describe('finalizeVoiceDisconnectForConnection', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('broadcasts a cleared voice_state when a voice-active connection is removed', () => {
createConnectedUser('conn-voice', {
voiceActive: true,
voiceStateSnapshot: {
type: 'voice_state',
serverId: 'server-1',
voiceState: {
isConnected: true,
isMuted: true,
isDeafened: false,
roomId: 'voice-1',
serverId: 'server-1'
}
}
});
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
getSentMessages(observer).length = 0;
finalizeVoiceDisconnectForConnection('conn-voice');
const messages = getSentMessages(observer).map((raw) => JSON.parse(raw) as {
type: string;
voiceState?: { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean };
});
const voiceState = messages.find((message) => message.type === 'voice_state');
expect(voiceState).toMatchObject({
type: 'voice_state',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false
}
});
expect(connectedUsers.get('conn-voice')?.voiceActive).toBe(false);
expect(connectedUsers.get('conn-voice')?.voiceStateSnapshot).toBeUndefined();
});
it('does nothing when the connection was not voice-active', () => {
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
createConnectedUser('conn-idle');
getSentMessages(observer).length = 0;
finalizeVoiceDisconnectForConnection('conn-idle');
expect(getSentMessages(observer)).toHaveLength(0);
});
});

View File

@@ -134,6 +134,59 @@ function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string):
});
}
function readVoiceStateServerId(snapshot: Record<string, unknown> | undefined): string | undefined {
if (!snapshot) {
return undefined;
}
const nestedVoiceState = snapshot['voiceState'];
if (nestedVoiceState && typeof nestedVoiceState === 'object') {
const nestedServerId = readMessageId((nestedVoiceState as { serverId?: unknown }).serverId);
if (nestedServerId) {
return nestedServerId;
}
}
return readMessageId(snapshot['serverId']);
}
/** Broadcast a cleared voice_state when a voice-active socket disappears without a graceful leave. */
export function finalizeVoiceDisconnectForConnection(connectionId: string): void {
const user = connectedUsers.get(connectionId);
if (!user?.authenticated || (!user.voiceActive && !user.voiceStateSnapshot)) {
return;
}
const serverId = readVoiceStateServerId(user.voiceStateSnapshot) ?? user.viewedServerId;
if (serverId && user.serverIds.has(serverId)) {
broadcastToServer(
serverId,
{
type: 'voice_state',
serverId,
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false
}
},
{ excludeConnectionId: connectionId }
);
}
user.voiceActive = false;
user.voiceStateSnapshot = undefined;
connectedUsers.set(connectionId, user);
clearVoiceActiveForOderId(user.oderId, connectionId);
}
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
user.ws.send(JSON.stringify({
type: 'voice_state',

View File

@@ -11,7 +11,7 @@ import {
getServerIdsForOderId,
isOderIdConnectedToServer
} from './broadcast';
import { handleWebSocketMessage } from './handler';
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
finalizeVoiceDisconnectForConnection(connectionId);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
user.serverIds.forEach((sid) => {