From 13241345de8c87b63966a36085b1712776e365a4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:11:20 +0200 Subject: [PATCH] feat(server): surface dm_call_end relay failure to originator (TDD) --- .../server/src/ws/events.dmCallRelay.test.ts | 45 +++++++++++++++++++ packages/server/src/ws/events.ts | 34 ++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/server/src/ws/events.dmCallRelay.test.ts b/packages/server/src/ws/events.dmCallRelay.test.ts index 8bd6cbc9..a72369d4 100644 --- a/packages/server/src/ws/events.dmCallRelay.test.ts +++ b/packages/server/src/ws/events.dmCallRelay.test.ts @@ -113,6 +113,51 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('handleDmCallEnd Path-2 relay failure', () => { + it('emits dm_call_undeliverable { phase:"end", terminal:false } when the relay fails', async () => { + const { handleDmCallEndForTest } = await importSUT(); + const connectionManager = await importManager(); + + const fedCall = makeFedCall({ state: 'active' }); + connectionManager.createFederatedCall(fedCall); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); + sendCallRelayMock.mockResolvedValue({ ok: false, reason: 'peer_transient_failure', error: 'timeout' }); + + await handleDmCallEndForTest( + { 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('end'); + expect(ev.terminal).toBe(false); + }); + + it('does not emit undeliverable on relay success', async () => { + const { handleDmCallEndForTest } = await importSUT(); + const connectionManager = await importManager(); + + const fedCall = makeFedCall({ state: 'active' }); + connectionManager.createFederatedCall(fedCall); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); + sendCallRelayMock.mockResolvedValue({ ok: true }); + + await handleDmCallEndForTest( + { 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('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(); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index c18bb179..edcafe04 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -206,7 +206,9 @@ export function handleClientEvent( ); break; case 'dm_call_end': - handleDmCallEnd(event, userId); + handleDmCallEnd(event, userId).catch(err => + console.error('[ws] handleDmCallEnd error:', err), + ); break; case 'voice_status': handleVoiceStatus(event, userId); @@ -1654,7 +1656,7 @@ async function handleDmCallReject(event: Record, userId: string } } -function handleDmCallEnd(event: Record, userId: string): void { +async function handleDmCallEnd(event: Record, userId: string): Promise { let dmChannelId = (event.dmChannelId as string) || null; const federatedCallId = (event.federatedCallId as string) || null; @@ -1698,14 +1700,16 @@ function handleDmCallEnd(event: Record, userId: string): void { : undefined; if (fedCall) { - // Exclude the user who ended the call — they already disconnected in their click handler. - // Sending dm_call_ended back to them causes redundant disconnectFn() and double sounds. + // Exclude the user who ended the call — they already disconnected client-side. connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { type: 'dm_call_ended', 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 }) @@ -1714,16 +1718,29 @@ function handleDmCallEnd(event: Record, userId: string): void { .get(); const homeUserId = user?.homeUserId || userId; - sendCallRelay(fedCall.federatedCallHost, [{ + const result = await sendCallRelay(host, [{ eventType: 'dm_call_end', messageId: generateSnowflake(), encryptionVersion: 0, timestamp: Date.now(), - federatedId: fedCall.federatedId, + federatedId: fedId, call: { endedBy: { homeUserId, homeInstance: getOurOrigin() }, }, - }]).catch(err => console.error('[federation] Failed to send dm_call_end:', err)); + }]); + + if (!result.ok) { + console.error(`[federation] dm_call_end 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: 'end', + failures: [failure], + }); + } } } @@ -2374,3 +2391,4 @@ function handleVoiceDisconnect(event: Record, userId: string): /** Direct export for unit tests — do not use in production code paths. */ export const handleDmCallAcceptForTest = handleDmCallAccept; export const handleDmCallRejectForTest = handleDmCallReject; +export const handleDmCallEndForTest = handleDmCallEnd;