Skip to content

Commit 6208f1a

Browse files
fix(rtc): emit events to handlers in registration order
1 parent ed8c9ce commit 6208f1a

2 files changed

Lines changed: 112 additions & 6 deletions

File tree

livekit-rtc/livekit/rtc/event_emitter.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
import inspect
3-
from typing import Any, Callable, Dict, Set, Optional, Generic, TypeVar
3+
from typing import Any, Callable, Dict, Optional, Generic, TypeVar
44

55
from .log import logger
66

@@ -21,7 +21,10 @@ def __init__(self) -> None:
2121
"""
2222
Initialize a new instance of EventEmitter.
2323
"""
24-
self._events: Dict[T_contra, Set[Callable]] = dict()
24+
# A dict keyed by callback is an insertion-ordered set: handlers run in the
25+
# order they were registered, so one that mutates the event still runs before
26+
# a peer that reads it.
27+
self._events: Dict[T_contra, Dict[Callable, None]] = dict()
2528

2629
def emit(self, event: T_contra, *args: Any) -> None:
2730
"""
@@ -45,7 +48,7 @@ def greet(name):
4548
```
4649
"""
4750
if event in self._events:
48-
callables = self._events[event].copy()
51+
callables = list(self._events[event])
4952
for callback in callables:
5053
try:
5154
sig = inspect.signature(callback)
@@ -175,8 +178,8 @@ def greet(name):
175178
)
176179

177180
if event not in self._events:
178-
self._events[event] = set()
179-
self._events[event].add(callback)
181+
self._events[event] = {}
182+
self._events[event][callback] = None
180183
return callback
181184
else:
182185

@@ -209,4 +212,4 @@ def greet(name):
209212
```
210213
"""
211214
if event in self._events:
212-
self._events[event].discard(callback)
215+
self._events[event].pop(callback, None)

tests/rtc/test_emitter.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,106 @@ def test_on_rejects_async_mock() -> None:
184184

185185
with pytest.raises(ValueError, match="Cannot register an async callback"):
186186
emitter.on("connected", AsyncMock())
187+
188+
189+
class _OrderedHandler:
190+
"""A callable whose hash is fixed, so a set orders it independently of registration."""
191+
192+
def __init__(self, name: str, hash_value: int, sink: list[str]) -> None:
193+
self._name = name
194+
self._hash = hash_value
195+
self._sink = sink
196+
197+
def __hash__(self) -> int:
198+
return self._hash
199+
200+
def __eq__(self, other: object) -> bool:
201+
return self is other
202+
203+
def __call__(self) -> None:
204+
self._sink.append(self._name)
205+
206+
207+
def test_handlers_run_in_registration_order() -> None:
208+
# Handlers were kept in a set, so dispatch order was hash-derived. The hashes here are
209+
# picked so a set yields them in the opposite order to the one they were added in.
210+
emitter = EventEmitter[str]()
211+
order: list[str] = []
212+
213+
emitter.on("event", _OrderedHandler("first", 5, order))
214+
emitter.on("event", _OrderedHandler("second", 1, order))
215+
216+
emitter.emit("event")
217+
assert order == ["first", "second"]
218+
219+
220+
def test_a_mutating_handler_runs_before_a_peer_that_reads_it() -> None:
221+
# The livekit-agents case: one handler stamps a field onto the emitted object and a
222+
# user handler registered later reads it. Registration order has to decide.
223+
class Event:
224+
def __init__(self) -> None:
225+
self.speech_id: Any = None
226+
227+
class Stamp:
228+
def __hash__(self) -> int:
229+
return 5
230+
231+
def __eq__(self, other: object) -> bool:
232+
return self is other
233+
234+
def __call__(self, ev: Event) -> None:
235+
ev.speech_id = "speech_1"
236+
237+
class Read:
238+
def __init__(self, sink: list[Any]) -> None:
239+
self._sink = sink
240+
241+
def __hash__(self) -> int:
242+
return 1
243+
244+
def __eq__(self, other: object) -> bool:
245+
return self is other
246+
247+
def __call__(self, ev: Event) -> None:
248+
self._sink.append(ev.speech_id)
249+
250+
emitter = EventEmitter[str]()
251+
seen: list[Any] = []
252+
emitter.on("metrics", Stamp())
253+
emitter.on("metrics", Read(seen))
254+
255+
for _ in range(5):
256+
emitter.emit("metrics", Event())
257+
258+
assert seen == ["speech_1"] * 5
259+
260+
261+
def test_off_still_removes_a_handler() -> None:
262+
emitter = EventEmitter[str]()
263+
calls: list[str] = []
264+
265+
@emitter.on("event")
266+
def keep() -> None:
267+
calls.append("keep")
268+
269+
@emitter.on("event")
270+
def drop() -> None:
271+
calls.append("drop")
272+
273+
emitter.off("event", drop)
274+
emitter.off("event", drop) # removing twice must not raise
275+
emitter.emit("event")
276+
assert calls == ["keep"]
277+
278+
279+
def test_registering_the_same_handler_twice_keeps_one_entry() -> None:
280+
emitter = EventEmitter[str]()
281+
calls: list[str] = []
282+
283+
def handler() -> None:
284+
calls.append("x")
285+
286+
emitter.on("event", handler)
287+
emitter.on("event", handler)
288+
emitter.emit("event")
289+
assert calls == ["x"]

0 commit comments

Comments
 (0)