Skip to content

Commit 4182a1d

Browse files
authored
fix: Room.getSid() never resolves when the JoinResponse carries an empty room sid (#1152)
Fixes #1151
1 parent 9e05b79 commit 4182a1d

3 files changed

Lines changed: 192 additions & 3 deletions

File tree

.changes/room-getsid-wrong-emitter

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Room.getSid() now resolves when the room sid is issued after the join response"

lib/src/core/room.dart

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
111111

112112
lk_models.Room? _roomInfo;
113113

114+
// Pending getSid() waiters, completed with '' at disposal so a Room.dispose
115+
// without a disconnect event can't leave them hanging. Tracked as a field
116+
// (and drained by the constructor's dispose routine) so repeated getSid()
117+
// calls don't accumulate per-call onDispose closures.
118+
final Set<Completer<String>> _pendingSidCompleters = {};
119+
114120
/// a list of participants that are actively speaking, including local participant.
115121
UnmodifiableListView<Participant> get activeSpeakers => UnmodifiableListView<Participant>(_activeSpeakers);
116122
List<Participant> _activeSpeakers = [];
@@ -203,6 +209,13 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
203209
preConnectAudioBuffer = PreConnectAudioBuffer(this);
204210

205211
onDispose(() async {
212+
// complete pending getSid() waiters so they don't hang on teardown
213+
for (final completer in _pendingSidCompleters) {
214+
if (!completer.isCompleted) {
215+
completer.complete('');
216+
}
217+
}
218+
_pendingSidCompleters.clear();
206219
// clean up routine
207220
await _cleanUp();
208221
// reject any in-flight RPC calls
@@ -1103,19 +1116,52 @@ extension RoomPrivateMethods on Room {
11031116

11041117
final completer = Completer<String>();
11051118

1106-
events.on<SignalRoomUpdateEvent>((event) {
1119+
// SignalRoomUpdateEvent is emitted on the signal client's emitter (and
1120+
// consumed by _setUpSignalListeners) — it never appears on the Room's
1121+
// [events], so listen where it actually fires or the future returned
1122+
// here never completes. Created via createListener() so it is cancelled
1123+
// with its owner.
1124+
final roomUpdateListener = engine.signalClient.createListener();
1125+
roomUpdateListener.on<SignalRoomUpdateEvent>((event) {
11071126
if (event.room.sid.isNotEmpty && !completer.isCompleted) {
11081127
completer.complete(event.room.sid);
11091128
}
11101129
});
11111130

1112-
events.once<RoomDisconnectedEvent>((event) {
1131+
// A caller waiting while the connection is still being established: the
1132+
// sid may arrive inside the JoinResponse, which is applied via
1133+
// EngineJoinResponseEvent without a SignalRoomUpdateEvent.
1134+
final joinListener = engine.createListener();
1135+
joinListener.on<EngineJoinResponseEvent>((event) {
1136+
if (event.response.room.sid.isNotEmpty && !completer.isCompleted) {
1137+
completer.complete(event.response.room.sid);
1138+
}
1139+
});
1140+
1141+
final cancelDisconnectListen = events.once<RoomDisconnectedEvent>((event) {
11131142
if (!completer.isCompleted) {
11141143
completer.complete('');
11151144
}
11161145
});
11171146

1118-
return completer.future;
1147+
// Disposal without a disconnect event (Room.dispose during teardown)
1148+
// cancels the listeners above — the constructor's dispose routine
1149+
// completes every tracked waiter with '' instead of leaving the returned
1150+
// future pending forever.
1151+
_pendingSidCompleters.add(completer);
1152+
1153+
// The update may have been applied between the check above and the
1154+
// listener registration.
1155+
if (_roomInfo != null && _roomInfo!.sid.isNotEmpty && !completer.isCompleted) {
1156+
completer.complete(_roomInfo!.sid);
1157+
}
1158+
1159+
return completer.future.whenComplete(() async {
1160+
_pendingSidCompleters.remove(completer);
1161+
await roomUpdateListener.dispose();
1162+
await joinListener.dispose();
1163+
await cancelDisconnectListen?.call();
1164+
});
11191165
}
11201166
}
11211167

test/core/room_sid_test.dart

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@Timeout(Duration(seconds: 5))
16+
library;
17+
18+
import 'package:flutter_test/flutter_test.dart';
19+
20+
import 'package:livekit_client/livekit_client.dart';
21+
import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models;
22+
import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_rtc;
23+
import '../mock/e2e_container.dart';
24+
import '../mock/test_data.dart';
25+
import 'signal_client_test.dart';
26+
27+
/// Room sids are assigned asynchronously by the server: the JoinResponse can
28+
/// carry an empty `room.sid`, with the real sid following in a RoomUpdate.
29+
/// `getSid()` must resolve when that update arrives — it used to wait for
30+
/// `SignalRoomUpdateEvent` on the Room's own emitter, where that (signal)
31+
/// event is never emitted, so the returned future never completed.
32+
void main() {
33+
TestWidgetsFlutterBinding.ensureInitialized();
34+
35+
final lk_rtc.SignalResponse emptySidJoinResponse = lk_rtc.SignalResponse(
36+
join: lk_rtc.JoinResponse(
37+
room: lk_models.Room(
38+
name: 'room_name',
39+
// sid deliberately unset — issued later via RoomUpdate.
40+
),
41+
participant: localParticipantData,
42+
subscriberPrimary: true,
43+
serverVersion: '99.999',
44+
serverInfo: lk_models.ServerInfo(
45+
version: '1.8.0',
46+
),
47+
),
48+
);
49+
50+
final lk_rtc.SignalResponse sidRoomUpdateResponse = lk_rtc.SignalResponse(
51+
roomUpdate: lk_rtc.RoomUpdate(
52+
room: lk_models.Room(
53+
name: 'room_name',
54+
sid: 'RM_issued_later',
55+
),
56+
),
57+
);
58+
59+
/// Connects the container's room, answering with [joinResp] instead of the
60+
/// default join response.
61+
Future<void> connectWith(E2EContainer container, lk_rtc.SignalResponse joinResp) async {
62+
final connectFuture = container.room.connect(exampleUri, token);
63+
Future.delayed(const Duration(milliseconds: 1), () {
64+
container.wsConnector.onData(joinResp.writeToBuffer());
65+
container.wsConnector.onData(offerResponse.writeToBuffer());
66+
});
67+
await connectFuture;
68+
}
69+
70+
late E2EContainer container;
71+
72+
setUp(() async {
73+
container = E2EContainer();
74+
});
75+
76+
tearDown(() async {
77+
await container.dispose();
78+
});
79+
80+
group('Room.getSid', () {
81+
test('returns immediately when the join response carried the sid', () async {
82+
await connectWith(container, joinResponse);
83+
84+
expect(await container.room.getSid(), 'room_sid');
85+
});
86+
87+
test('resolves when the sid arrives via a later RoomUpdate', () async {
88+
await connectWith(container, emptySidJoinResponse);
89+
90+
final sidFuture = container.room.getSid();
91+
container.wsConnector.onData(sidRoomUpdateResponse.writeToBuffer());
92+
93+
expect(await sidFuture, 'RM_issued_later');
94+
});
95+
96+
test('completes with an empty sid when the room is disposed while waiting', () async {
97+
await connectWith(container, emptySidJoinResponse);
98+
99+
final sidFuture = container.room.getSid();
100+
await container.room.dispose();
101+
102+
expect(await sidFuture, '');
103+
});
104+
105+
test('resolves with the join-response sid for callers waiting during connect', () async {
106+
final connectFuture = container.room.connect(exampleUri, token);
107+
Future.delayed(const Duration(milliseconds: 5), () {
108+
container.wsConnector.onData(joinResponse.writeToBuffer());
109+
container.wsConnector.onData(offerResponse.writeToBuffer());
110+
});
111+
// Ask while the signal connection is still being established.
112+
await Future<void>.delayed(const Duration(milliseconds: 1));
113+
final sidFuture = container.room.getSid();
114+
115+
await connectFuture;
116+
expect(await sidFuture, 'room_sid');
117+
});
118+
119+
test('repeated calls do not accumulate dispose hooks', () async {
120+
await connectWith(container, emptySidJoinResponse);
121+
final hooksBefore = container.room.disposeFuncCount;
122+
123+
for (var i = 0; i < 3; i++) {
124+
final sidFuture = container.room.getSid();
125+
container.wsConnector.onData(sidRoomUpdateResponse.writeToBuffer());
126+
await sidFuture;
127+
}
128+
129+
expect(container.room.disposeFuncCount, hooksBefore);
130+
});
131+
132+
test('resolves for a caller arriving after the RoomUpdate landed', () async {
133+
await connectWith(container, emptySidJoinResponse);
134+
135+
container.wsConnector.onData(sidRoomUpdateResponse.writeToBuffer());
136+
// Let the signal event propagate to _applyRoomUpdate.
137+
await Future<void>.delayed(const Duration(milliseconds: 10));
138+
139+
expect(await container.room.getSid(), 'RM_issued_later');
140+
});
141+
});
142+
}

0 commit comments

Comments
 (0)