fix: Bug - User automatically leaves voice after short period of time
All checks were successful
Queue Release Build / prepare (push) Successful in 22s
Deploy Web Apps / deploy (push) Successful in 7m32s
Queue Release Build / build-windows (push) Successful in 27m41s
Queue Release Build / build-linux (push) Successful in 44m56s
Queue Release Build / build-android (push) Successful in 18m52s
Queue Release Build / finalize (push) Successful in 21s

Ignore stale P2P self-disconnect voice-state echoes while this client actively owns voice, refresh noise-reduction input on re-join, and repair dual-signal E2E harness expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 04:04:31 +02:00
parent dac5cb42a5
commit c3c2f01cc6
10 changed files with 345 additions and 226 deletions

View File

@@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient,
shouldApplyRemoteVoiceStateToCurrentUser,
shouldTransmitVoice
} from './client-voice-session.rules';
@@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => {
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
it('ignores stale self disconnect updates while this client actively owns voice', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: 'vc-general',
serverId: 'server-1',
clientInstanceId: localClientInstanceId
};
expect(shouldApplyRemoteVoiceStateToCurrentUser(
voiceState,
{ isConnected: false },
localClientInstanceId,
true
)).toBe(false);
});
it('applies self disconnect updates when this client is not actively transmitting', () => {
expect(shouldApplyRemoteVoiceStateToCurrentUser(
{
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: localClientInstanceId
},
{ isConnected: false },
localClientInstanceId,
false
)).toBe(true);
});
it('ignores self disconnect echoes during the join race before clientInstanceId is stored', () => {
expect(shouldApplyRemoteVoiceStateToCurrentUser(
{
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: 'vc-general',
serverId: 'server-1'
},
{ isConnected: false },
localClientInstanceId,
true
)).toBe(false);
});
});

View File

@@ -32,3 +32,29 @@ export function shouldTransmitVoice(
return voiceState.clientInstanceId === clientInstanceId;
}
/** Ignore stale P2P disconnect echoes for the current user while this client actively owns voice. */
export function shouldApplyRemoteVoiceStateToCurrentUser(
currentVoiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
incoming: Partial<VoiceState>,
localClientInstanceId: string,
isLocallyVoiceActive: boolean
): boolean {
if (incoming.isConnected !== false) {
return true;
}
if (!isLocallyVoiceActive) {
return true;
}
if (isLocalVoiceOwner(currentVoiceState, localClientInstanceId)) {
return false;
}
if (!currentVoiceState?.clientInstanceId) {
return false;
}
return true;
}

View File

@@ -0,0 +1,40 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import { NoiseReductionManager } from './noise-reduction.manager';
describe('NoiseReductionManager', () => {
it('replaces the input stream when noise reduction is already enabled', async () => {
const manager = new NoiseReductionManager({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
} as never);
const rawStream = createFakeMediaStream('fresh-track');
const replacedStream = createFakeMediaStream('clean-track');
vi.spyOn(manager, 'replaceInputStream').mockResolvedValue(replacedStream);
const enabledManager = manager as NoiseReductionManager & {
_isEnabled: boolean;
destinationNode: { stream: MediaStream };
};
enabledManager._isEnabled = true;
enabledManager.destinationNode = { stream: createFakeMediaStream('stale-track') };
await expect(manager.enable(rawStream)).resolves.toBe(replacedStream);
expect(manager.replaceInputStream).toHaveBeenCalledWith(rawStream);
});
});
function createFakeMediaStream(trackId: string): MediaStream {
return {
getAudioTracks: () => [{ id: trackId }],
getVideoTracks: () => [],
getTracks: () => [{ id: trackId }]
} as MediaStream;
}

View File

@@ -66,8 +66,8 @@ export class NoiseReductionManager {
*/
async enable(rawStream: MediaStream): Promise<MediaStream> {
if (this._isEnabled && this.destinationNode) {
this.logger.info('Noise reduction already enabled, returning existing clean stream');
return this.destinationNode.stream;
this.logger.info('Noise reduction already enabled, replacing input stream');
return this.replaceInputStream(rawStream);
}
try {

View File

@@ -44,7 +44,7 @@ import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules';
import { isVoiceOnAnotherClient, shouldApplyRemoteVoiceStateToCurrentUser } from '../../domains/voice-session';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
import {
@@ -563,6 +563,18 @@ export class RoomStateSyncEffects {
);
}
if (
isCurrentUserEvent
&& !shouldApplyRemoteVoiceStateToCurrentUser(
currentUser?.voiceState,
vs,
localClientInstanceId,
this.webrtc.isVoiceConnected()
)
) {
return presenceRefreshAction ? of(presenceRefreshAction) : EMPTY;
}
const actions: Action[] = [];
if (presenceRefreshAction) {