Skip to content

Commit 2328913

Browse files
authored
Fix subscriber data channel state events reporting the wrong channel (#1172)
_onDataChannel registered a nested listener: every state change on a subscriber data channel attached a new listener to the publisher reliable channel and emitted SubscriberDataChannelStateUpdatedEvent from that channel's state instead. So subscriber state changes produced no immediate event, the events that did arrive carried the publisher channel's state (for the lossy label too), and inner listeners accumulated uncancelled for the lifetime of the connection. Now each subscriber channel emits its own state directly from a single listener, mirroring the publisher-side wiring. Also initializes the late stateChangeStream field in MockDataChannel, which previously threw LateInitializationError whenever engine code touched it in tests. Regression test verified to fail against the old wiring. Pre-existing bug surfaced by Devin on the #1170 reformat diff, fixed separately to keep that PR mechanical.
1 parent 2f09e20 commit 2328913

4 files changed

Lines changed: 100 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Subscriber data channel state events now report the subscriber channel state and correct reliability type, and no longer leak listeners"

lib/src/core/engine.dart

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -809,23 +809,21 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
809809
logger.fine('Server opened DC label: ${dc.label}');
810810
_reliableDCSub = dc;
811811
_reliableDCSub?.onMessage = _onDCMessage;
812-
_reliableDCSub?.stateChangeStream.listen((state) =>
813-
_reliableDCPub?.stateChangeStream.listen((state) => events.emit(SubscriberDataChannelStateUpdatedEvent(
814-
isPrimary: _subscriberPrimary,
815-
state: state,
816-
type: Reliability.reliable,
817-
))));
812+
_reliableDCSub?.stateChangeStream.listen((state) => events.emit(SubscriberDataChannelStateUpdatedEvent(
813+
isPrimary: _subscriberPrimary,
814+
state: state,
815+
type: Reliability.reliable,
816+
)));
818817
break;
819818
case _lossyDCLabel:
820819
logger.fine('Server opened DC label: ${dc.label}');
821820
_lossyDCSub = dc;
822821
_lossyDCSub?.onMessage = _onDCMessage;
823-
_lossyDCSub?.stateChangeStream.listen((event) =>
824-
_reliableDCPub?.stateChangeStream.listen((state) => events.emit(SubscriberDataChannelStateUpdatedEvent(
825-
isPrimary: _subscriberPrimary,
826-
state: state,
827-
type: Reliability.lossy,
828-
))));
822+
_lossyDCSub?.stateChangeStream.listen((state) => events.emit(SubscriberDataChannelStateUpdatedEvent(
823+
isPrimary: _subscriberPrimary,
824+
state: state,
825+
type: Reliability.lossy,
826+
)));
829827
break;
830828
default:
831829
logger.warning('Unknown DC label: ${dc.label}');
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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: 10))
16+
library;
17+
18+
import 'package:flutter_test/flutter_test.dart';
19+
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
20+
21+
import 'package:livekit_client/livekit_client.dart';
22+
import 'package:livekit_client/src/internal/events.dart';
23+
import '../mock/datachannel_mock.dart';
24+
import '../mock/e2e_container.dart';
25+
import '../mock/peerconnection_mock.dart';
26+
27+
void main() {
28+
setUp(resetMockDataChannels);
29+
30+
group('subscriber data channel state events', () {
31+
// Regression: _onDataChannel used to register a nested listener on the
32+
// publisher reliable channel instead of emitting the subscriber channel's
33+
// own state. Subscriber state changes produced no immediate event, later
34+
// events carried the wrong channel's state, and inner listeners
35+
// accumulated on every state change.
36+
test('emit the subscriber channel state with the correct reliability type', () async {
37+
final container = E2EContainer();
38+
addTearDown(container.dispose);
39+
await container.connectRoom();
40+
41+
final engine = container.room.engine;
42+
final events = <SubscriberDataChannelStateUpdatedEvent>[];
43+
final listener = engine.createListener()..on<SubscriberDataChannelStateUpdatedEvent>(events.add);
44+
addTearDown(listener.dispose);
45+
46+
// Simulate the server opening subscriber-side channels.
47+
final reliableSub = MockDataChannel(8, '_reliable');
48+
final lossySub = MockDataChannel(9, '_lossy');
49+
final onDataChannel = engine.subscriber!.pc.onDataChannel!;
50+
onDataChannel(reliableSub);
51+
onDataChannel(lossySub);
52+
53+
reliableSub.stateChangeStreamController.add(rtc.RTCDataChannelState.RTCDataChannelClosing);
54+
lossySub.stateChangeStreamController.add(rtc.RTCDataChannelState.RTCDataChannelClosed);
55+
await Future<void>.delayed(Duration.zero);
56+
57+
expect(events, hasLength(2));
58+
expect(events[0].type, Reliability.reliable);
59+
expect(events[0].state, rtc.RTCDataChannelState.RTCDataChannelClosing);
60+
expect(events[1].type, Reliability.lossy);
61+
expect(events[1].state, rtc.RTCDataChannelState.RTCDataChannelClosed);
62+
});
63+
64+
test('repeated state changes do not multiply events', () async {
65+
final container = E2EContainer();
66+
addTearDown(container.dispose);
67+
await container.connectRoom();
68+
69+
final engine = container.room.engine;
70+
final events = <SubscriberDataChannelStateUpdatedEvent>[];
71+
final listener = engine.createListener()..on<SubscriberDataChannelStateUpdatedEvent>(events.add);
72+
addTearDown(listener.dispose);
73+
74+
final reliableSub = MockDataChannel(8, '_reliable');
75+
engine.subscriber!.pc.onDataChannel!(reliableSub);
76+
77+
for (var i = 0; i < 3; i++) {
78+
reliableSub.stateChangeStreamController.add(rtc.RTCDataChannelState.RTCDataChannelOpen);
79+
}
80+
await Future<void>.delayed(Duration.zero);
81+
82+
expect(events, hasLength(3));
83+
expect(events.every((e) => e.state == rtc.RTCDataChannelState.RTCDataChannelOpen), isTrue);
84+
});
85+
});
86+
}

test/mock/datachannel_mock.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class MockDataChannel extends RTCDataChannel {
2424
late StreamController<RTCDataChannelState> stateChangeStreamController;
2525
MockDataChannel(this._id, this._label) {
2626
stateChangeStreamController = StreamController<RTCDataChannelState>.broadcast();
27+
// The base class declares this as a bare `late` field, reading it before
28+
// assignment throws LateInitializationError
29+
stateChangeStream = stateChangeStreamController.stream;
2730
}
2831

2932
@override

0 commit comments

Comments
 (0)