diff --git a/packages/server/src/ws/events.dmCallRelay.test.ts b/packages/server/src/ws/events.dmCallRelay.test.ts index 0363d216..8bd6cbc9 100644 --- a/packages/server/src/ws/events.dmCallRelay.test.ts +++ b/packages/server/src/ws/events.dmCallRelay.test.ts @@ -113,6 +113,53 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('handleDmCallReject Path-2 relay failure', () => { + it('emits dm_call_undeliverable { phase:"reject", terminal:false } to the rejector when the relay fails', async () => { + const { handleDmCallRejectForTest } = await importSUT(); + const connectionManager = await importManager(); + + const fedCall = makeFedCall(); + connectionManager.createFederatedCall(fedCall); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); + sendCallRelayMock.mockResolvedValue({ ok: false, reason: 'peer_rejected', error: 'rejected' }); + + await handleDmCallRejectForTest( + { federatedCallId: fedCall.federatedId }, + fedCall.ringedUserIds[0]!, + ); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(1); + const ev = undelivCalls[0]![1] as { phase: string; terminal: boolean }; + expect(ev.phase).toBe('reject'); + expect(ev.terminal).toBe(false); + // Local state was cleared before the relay even fired. + expect(connectionManager.getFederatedCall(fedCall.federatedId)).toBeUndefined(); + }); + + it('does not emit undeliverable on relay success', async () => { + const { handleDmCallRejectForTest } = await importSUT(); + const connectionManager = await importManager(); + + const fedCall = makeFedCall(); + connectionManager.createFederatedCall(fedCall); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); + sendCallRelayMock.mockResolvedValue({ ok: true }); + + await handleDmCallRejectForTest( + { federatedCallId: fedCall.federatedId }, + fedCall.ringedUserIds[0]!, + ); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(0); + }); +}); + describe('handleDmCallAccept Path-2 relay failure', () => { it('emits dm_call_undeliverable { phase:"accept", terminal:true } and clears the fedCall when the relay fails', async () => { const { handleDmCallAcceptForTest } = await importSUT(); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index ba281890..c18bb179 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -201,7 +201,9 @@ export function handleClientEvent( ); break; case 'dm_call_reject': - handleDmCallReject(event, userId); + handleDmCallReject(event, userId).catch(err => + console.error('[ws] handleDmCallReject error:', err), + ); break; case 'dm_call_end': handleDmCallEnd(event, userId); @@ -1568,7 +1570,7 @@ async function handleDmCallAccept(event: Record, userId: string connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); } -function handleDmCallReject(event: Record, userId: string): void { +async function handleDmCallReject(event: Record, userId: string): Promise { let dmChannelId = (event.dmChannelId as string) || null; const federatedCallId = (event.federatedCallId as string) || null; @@ -1608,13 +1610,16 @@ function handleDmCallReject(event: Record, userId: string): voi : undefined; if (fedCall) { - // Exclude the rejecting user — they already handled their own state + // Optimistic local clear — user intent is to reject. connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { type: 'dm_call_rejected', dmChannelId: fedCall.dmChannelId, federatedCallId: fedCall.federatedId, } as ServerEvent, userId); - connectionManager.clearFederatedCall(fedCall.federatedId); + const host = fedCall.federatedCallHost; + const fedId = fedCall.federatedId; + const dmId = fedCall.dmChannelId; + connectionManager.clearFederatedCall(fedId); const db = getDb(); const user = db.select({ homeUserId: schema.users.homeUserId }) @@ -1623,16 +1628,29 @@ function handleDmCallReject(event: Record, userId: string): voi .get(); const homeUserId = user?.homeUserId || userId; - sendCallRelay(fedCall.federatedCallHost, [{ + const result = await sendCallRelay(host, [{ eventType: 'dm_call_reject', messageId: generateSnowflake(), encryptionVersion: 0, timestamp: Date.now(), - federatedId: fedCall.federatedId, + federatedId: fedId, call: { rejector: { homeUserId, homeInstance: getOurOrigin() }, }, - }]).catch(err => console.error('[federation] Failed to send dm_call_reject:', err)); + }]); + + if (!result.ok) { + console.error(`[federation] dm_call_reject relay to ${host} failed (${result.reason}): ${result.error}`); + const failure = buildFailureFromResult(result, host, db); + connectionManager.sendToUser(userId, { + type: 'dm_call_undeliverable', + dmChannelId: dmId, + federatedCallId: fedId, + terminal: false, + phase: 'reject', + failures: [failure], + }); + } } } @@ -2355,3 +2373,4 @@ function handleVoiceDisconnect(event: Record, userId: string): // ─── Test-only exports ────────────────────────────────────────────────────── /** Direct export for unit tests — do not use in production code paths. */ export const handleDmCallAcceptForTest = handleDmCallAccept; +export const handleDmCallRejectForTest = handleDmCallReject;