diff --git a/packages/server/src/ws/events.dmCallRelay.test.ts b/packages/server/src/ws/events.dmCallRelay.test.ts index a1b08450..26547070 100644 --- a/packages/server/src/ws/events.dmCallRelay.test.ts +++ b/packages/server/src/ws/events.dmCallRelay.test.ts @@ -234,13 +234,19 @@ describe('ring-timeout fan-out hook', () => { }); describe('handleDmCallAccept Path-2 relay failure', () => { - it('emits dm_call_undeliverable { phase:"accept", terminal:true } and clears the fedCall when the relay fails', async () => { + it('emits dm_call_undeliverable { phase:"accept", terminal:true } to the acceptor ONLY and clears the fedCall when the relay fails', async () => { const { handleDmCallAcceptForTest } = await importSUT(); const connectionManager = await importManager(); - const fedCall = makeFedCall(); + const fedCall = makeFedCall({ + // Two ringed users — a group DM where only one accepts. Only the acceptor + // should receive the terminal undeliverable; the other ringee must stay + // in ring state so their own timeout/reject path governs teardown. + ringedUserIds: ['acceptor-user', 'other-ringee'], + }); connectionManager.createFederatedCall(fedCall); - const sendToUsersSpy = vi.spyOn(connectionManager, 'sendToFederatedCallUsers'); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); + const sendToCallUsersSpy = vi.spyOn(connectionManager, 'sendToFederatedCallUsers'); sendCallRelayMock.mockResolvedValue({ ok: false, reason: 'peer_transient_failure', error: 'timeout' }); await handleDmCallAcceptForTest( @@ -250,14 +256,23 @@ describe('handleDmCallAccept Path-2 relay failure', () => { ); expect(connectionManager.getFederatedCall(fedCall.federatedId)).toBeUndefined(); - const calls = sendToUsersSpy.mock.calls.filter(([, ev]) => + + // Terminal undeliverable went to the acceptor only. + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => (ev as { type: string }).type === 'dm_call_undeliverable', ); - expect(calls).toHaveLength(1); - const undeliverable = calls[0]![1] as { phase: string; terminal: boolean; failures: unknown[] }; + expect(undelivCalls).toHaveLength(1); + expect(undelivCalls[0]![0]).toBe('acceptor-user'); + const undeliverable = undelivCalls[0]![1] as { phase: string; terminal: boolean; failures: unknown[] }; expect(undeliverable.phase).toBe('accept'); expect(undeliverable.terminal).toBe(true); expect(undeliverable.failures).toHaveLength(1); + + // No undeliverable broadcast to all ringed users. + const callUsersCalls = sendToCallUsersSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(callUsersCalls).toHaveLength(0); }); it('does not emit undeliverable on relay success', async () => { @@ -266,7 +281,7 @@ describe('handleDmCallAccept Path-2 relay failure', () => { const fedCall = makeFedCall(); connectionManager.createFederatedCall(fedCall); - const sendToUsersSpy = vi.spyOn(connectionManager, 'sendToFederatedCallUsers'); + const sendToUserSpy = vi.spyOn(connectionManager, 'sendToUser'); sendCallRelayMock.mockResolvedValue({ ok: true }); await handleDmCallAcceptForTest( @@ -275,7 +290,7 @@ describe('handleDmCallAccept Path-2 relay failure', () => { {} as never, ); - const undelivCalls = sendToUsersSpy.mock.calls.filter(([, ev]) => + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => (ev as { type: string }).type === 'dm_call_undeliverable', ); expect(undelivCalls).toHaveLength(0); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 4785d207..61955143 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1563,7 +1563,12 @@ async function handleDmCallAccept(event: Record, userId: string if (!result.ok) { console.error(`[federation] dm_call_accept relay to ${fedCall.federatedCallHost} failed (${result.reason}): ${result.error}`); const failure = buildFailureFromResult(result, fedCall.federatedCallHost, db); - connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { + // Clear first so a concurrent end-handler sees a cleared entry (idempotent). + connectionManager.clearFederatedCall(fedCall.federatedId); + // Terminal targets ONLY the acceptor — other ringed users (group DM) didn't + // accept and should stay in their ring state; their own dm_call_end / timeout + // paths govern their teardown. + connectionManager.sendToUser(userId, { type: 'dm_call_undeliverable', dmChannelId: fedCall.dmChannelId, federatedCallId: fedCall.federatedId, @@ -1571,7 +1576,6 @@ async function handleDmCallAccept(event: Record, userId: string phase: 'accept', failures: [failure], }); - connectionManager.clearFederatedCall(fedCall.federatedId); } return; }