rtc: record a room's departure when the participant leaves it (#4913)

* rtc: record a room's departure when the participant leaves it

RemoveParticipant removed the participant from the room, closed it, and
only then recorded leftAt. CloseIfEmpty runs every second, so a tick
landing during the close saw an empty room with a stale or zero
departure time and closed it at once: with no departure recorded it
compares against the room's creation time and empty_timeout, and with an
earlier departure it measures the grace period from that older
timestamp. Either way departure_timeout is skipped.

Record the departure where the participant is removed, under the lock
that already covers it.

* rtc: drop the removal wait from the departure timeout test

The close signal already pins the interleaving, and the room's close
state is decided by the CloseIfEmpty call itself.
This commit is contained in:
Martin Njalale authored and GitHub committed 2026-09-28 14:06:26 +05:30
1 parent 6cfd19bd9b
commit 5cd0f37342
2 files changed
+53 -2

No files matched your search

+3 -2
View File
@@ -1431,6 +1431,9 @@ func (r *Room) RemoveParticipant(
delete(r.hasPublished, identity)
delete(r.launchedTrackEgresses, identity)
delete(r.agentParticpants, identity)
// recorded while the participant is being removed, not after it is closed:
// the room is already empty to CloseIfEmpty, which runs every second
r.leftAt.Store(time.Now().Unix())
if !p.Hidden() {
r.protoRoom.NumParticipants--
}
@@ -1491,8 +1494,6 @@ func (r *Room) RemoveParticipant(
// close participant as well
_ = p.Close(true, reason, false)
r.leftAt.Store(time.Now().Unix())
if sendUpdates {
if r.onParticipantChanged != nil {
r.onParticipantChanged(p)
+50
View File
@@ -20,6 +20,7 @@ import (
"time"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"google.golang.org/protobuf/proto"
"github.com/livekit/protocol/auth/authfakes"
@@ -402,6 +403,55 @@ func TestRoomClosure(t *testing.T) {
require.Equal(t, ErrRoomClosed, rm.Join(p, nil, nil, iceServersForRoom))
})
// the idle check runs every second and can land while the last participant
// is still closing, when the room is empty but its departure has not been
// recorded yet
closeDuringRemove := func(t *testing.T, priorDeparture bool) bool {
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
var isClosed atomic.Bool
rm.OnClose(func(types.RoomCloseReason) {
isClosed.Store(true)
})
rm.lock.Lock()
rm.protoRoom.DepartureTimeout = 20
if priorDeparture {
// someone left earlier in the session, so a departure is recorded
rm.protoRoom.CreationTime = time.Now().Unix()
rm.leftAt.Store(time.Now().Add(-time.Minute).Unix())
} else {
// older than the empty timeout, as a room in a call for a while is
rm.protoRoom.CreationTime = time.Now().Add(-10 * time.Minute).Unix()
}
rm.lock.Unlock()
p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant)
closing := make(chan struct{})
release := make(chan struct{})
p.CloseStub = func(bool, types.ParticipantCloseReason, bool) error {
close(closing)
<-release
return nil
}
go rm.RemoveParticipant(p.Identity(), p.ID(), types.ParticipantCloseReasonClientRequestLeave)
<-closing
require.Empty(t, rm.GetParticipants())
rm.CloseIfEmpty()
close(release)
return isClosed.Load()
}
t.Run("room does not close while the last participant is closing", func(t *testing.T) {
require.False(t, closeDuringRemove(t, false))
})
t.Run("room does not close while the last participant is closing, after an earlier departure", func(t *testing.T) {
require.False(t, closeDuringRemove(t, true))
})
t.Run("room does not close before empty timeout", func(t *testing.T) {
rm := newRoomWithParticipants(t, testRoomOpts{num: 0})
isClosed := false