From 02f9fe1e00ee6c011e37752270e76ef30ea45cb5 Mon Sep 17 00:00:00 2001 From: eeeeeaaan Date: Wed, 26 Aug 2026 04:48:00 +0900 Subject: [PATCH] fix(call): defer LiveKit room teardown until after the ENDED/REJECTED commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminate() and end() called closeLiveKitRoom() inside the same @Transactional method as call.end(), so the other participant got kicked from LiveKit (a real-time push) before the status write had actually committed. Their client's post-disconnect status check could still read stale ACTIVE, sending them through the reconnect/retry path instead of straight to the ended screen -- both sides hanging up should land on the same screen at roughly the same time, not one on "통화 종료" and the other on a "재연결 중" -> "거절됨" detour. Co-Authored-By: Claude Sonnet 5 --- .../unithon/call/application/CallService.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ssu/unithon/call/application/CallService.java b/src/main/java/com/ssu/unithon/call/application/CallService.java index cea293e..b31ef3b 100644 --- a/src/main/java/com/ssu/unithon/call/application/CallService.java +++ b/src/main/java/com/ssu/unithon/call/application/CallService.java @@ -35,6 +35,8 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service public class CallService { @@ -174,7 +176,7 @@ public void terminate(UUID callId, String participantKey) { Call call = lockedCall(callId); if (call.getStatus() == CallStatus.ENDED) return; call.end(clock.instant()); - closeLiveKitRoom(call); + afterCommit(() -> closeLiveKitRoom(call)); } @Transactional(readOnly = true) @@ -320,7 +322,7 @@ public void end(UUID callId, String creatorKey) { } call.end(clock.instant()); - closeLiveKitRoom(call); + afterCommit(() -> closeLiveKitRoom(call)); } @Transactional(readOnly = true) @@ -345,6 +347,27 @@ private void cleanupStalePhoneCalls(String phoneNumber) { } } + /** + * Runs {@code action} only after this transaction commits. closeLiveKitRoom kicks the other + * participant's live LiveKit connection immediately (a real-time push, not a poll), which was + * racing the ENDED/REJECTED status write it's paired with: that write doesn't durably commit + * until the surrounding @Transactional method returns, so the kicked participant's client + * could poll /status right after being disconnected and still read the old ACTIVE status. + * Deferring to afterCommit makes the status change visible before the disconnect fires. + */ + private void afterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + private void closeLiveKitRoom(Call call) { for (ParticipantSlot slot : slots.findByCallId(call.getId())) { gateway.remove(call.getLivekitRoomName(), slot.getIdentity());