diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index f2891e79a..800d17e40 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -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) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 37055c738..7a565aba5 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -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