Skip to content

Commit c6ece0e

Browse files
fix(rtc): emit events to handlers in registration order
1 parent 184d5a6 commit c6ece0e

4 files changed

Lines changed: 144 additions & 6 deletions

File tree

.github/workflows/build-protocol.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
3232
with:
3333
submodules: true
34+
# A fork PR's head branch only exists in the contributor's repo.
35+
repository: ${{ github.event.pull_request.head.repo.full_name }}
3436
ref: ${{ github.event.pull_request.head.ref }}
3537

3638
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
@@ -50,6 +52,8 @@ jobs:
5052
run: ./generate_proto.sh
5153

5254
- name: Add changes
55+
# A fork PR's GITHUB_TOKEN is read-only; the verify step below covers it.
56+
if: github.event.pull_request.head.repo.full_name == github.repository
5357
uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11
5458
with:
5559
add: '["livekit-protocol/"]'
@@ -60,6 +64,18 @@ jobs:
6064
# generated stubs onto the PR head branch doesn't need a full fetch.
6165
fetch: false
6266

67+
- name: Verify generated stubs are current
68+
if: github.event.pull_request.head.repo.full_name != github.repository
69+
# `status`, not `diff`: a newly generated stub is untracked.
70+
run: |
71+
drift="$(git status --porcelain --ignore-submodules=all -- .)"
72+
if [ -n "$drift" ]; then
73+
echo "Generated stubs are out of date. Run ./generate_proto.sh and commit:"
74+
echo "$drift"
75+
git diff --ignore-submodules=all -- .
76+
exit 1
77+
fi
78+
6379
build_wheels:
6480
name: Build Protocol wheel/sdist
6581
runs-on: ubuntu-latest

.github/workflows/build-rtc.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
3232
with:
3333
submodules: true
34+
# A fork PR's head branch only exists in the contributor's repo.
35+
repository: ${{ github.event.pull_request.head.repo.full_name }}
3436
ref: ${{ github.event.pull_request.head.ref }}
3537

3638
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
@@ -49,6 +51,8 @@ jobs:
4951
run: ./generate_proto.sh
5052

5153
- name: Add changes
54+
# A fork PR's GITHUB_TOKEN is read-only; the verify step below covers it.
55+
if: github.event.pull_request.head.repo.full_name == github.repository
5256
uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11
5357
with:
5458
add: '["livekit-rtc/"]'
@@ -59,6 +63,18 @@ jobs:
5963
# generated stubs onto the PR head branch doesn't need a full fetch.
6064
fetch: false
6165

66+
- name: Verify generated stubs are current
67+
if: github.event.pull_request.head.repo.full_name != github.repository
68+
# `status`, not `diff`: a newly generated stub is untracked.
69+
run: |
70+
drift="$(git status --porcelain --ignore-submodules=all -- .)"
71+
if [ -n "$drift" ]; then
72+
echo "Generated stubs are out of date. Run ./generate_proto.sh and commit:"
73+
echo "$drift"
74+
git diff --ignore-submodules=all -- .
75+
exit 1
76+
fi
77+
6278
build_wheels:
6379
name: Build RTC wheels (${{ matrix.archs }})
6480
runs-on: ${{ matrix.os }}

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 inspect
22
import asyncio
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

@@ -12,7 +12,10 @@ def __init__(self) -> None:
1212
"""
1313
Initialize a new instance of EventEmitter.
1414
"""
15-
self._events: Dict[T_contra, Set[Callable]] = dict()
15+
# A dict keyed by callback is an insertion-ordered set: handlers run in the
16+
# order they were registered, so one that mutates the event still runs before
17+
# a peer that reads it.
18+
self._events: Dict[T_contra, Dict[Callable, None]] = dict()
1619

1720
def emit(self, event: T_contra, *args: Any) -> None:
1821
"""
@@ -36,7 +39,7 @@ def greet(name):
3639
```
3740
"""
3841
if event in self._events:
39-
callables = self._events[event].copy()
42+
callables = list(self._events[event])
4043
for callback in callables:
4144
try:
4245
sig = inspect.signature(callback)
@@ -163,8 +166,8 @@ def greet(name):
163166
)
164167

165168
if event not in self._events:
166-
self._events[event] = set()
167-
self._events[event].add(callback)
169+
self._events[event] = {}
170+
self._events[event][callback] = None
168171
return callback
169172
else:
170173

@@ -197,4 +200,4 @@ def greet(name):
197200
```
198201
"""
199202
if event in self._events:
200-
self._events[event].discard(callback)
203+
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
@@ -102,3 +102,106 @@ def on_error_another() -> None:
102102
emitter.emit("error")
103103

104104
assert len(calls) == 2
105+
106+
107+
class _OrderedHandler:
108+
"""A callable whose hash is fixed, so a set orders it independently of registration."""
109+
110+
def __init__(self, name: str, hash_value: int, sink: list[str]) -> None:
111+
self._name = name
112+
self._hash = hash_value
113+
self._sink = sink
114+
115+
def __hash__(self) -> int:
116+
return self._hash
117+
118+
def __eq__(self, other: object) -> bool:
119+
return self is other
120+
121+
def __call__(self) -> None:
122+
self._sink.append(self._name)
123+
124+
125+
def test_handlers_run_in_registration_order() -> None:
126+
# Handlers were kept in a set, so dispatch order was hash-derived. The hashes here are
127+
# picked so a set yields them in the opposite order to the one they were added in.
128+
emitter = EventEmitter[str]()
129+
order: list[str] = []
130+
131+
emitter.on("event", _OrderedHandler("first", 5, order))
132+
emitter.on("event", _OrderedHandler("second", 1, order))
133+
134+
emitter.emit("event")
135+
assert order == ["first", "second"]
136+
137+
138+
def test_a_mutating_handler_runs_before_a_peer_that_reads_it() -> None:
139+
# The livekit-agents case: one handler stamps a field onto the emitted object and a
140+
# user handler registered later reads it. Registration order has to decide.
141+
class Event:
142+
def __init__(self) -> None:
143+
self.speech_id: Any = None
144+
145+
class Stamp:
146+
def __hash__(self) -> int:
147+
return 5
148+
149+
def __eq__(self, other: object) -> bool:
150+
return self is other
151+
152+
def __call__(self, ev: Event) -> None:
153+
ev.speech_id = "speech_1"
154+
155+
class Read:
156+
def __init__(self, sink: list[Any]) -> None:
157+
self._sink = sink
158+
159+
def __hash__(self) -> int:
160+
return 1
161+
162+
def __eq__(self, other: object) -> bool:
163+
return self is other
164+
165+
def __call__(self, ev: Event) -> None:
166+
self._sink.append(ev.speech_id)
167+
168+
emitter = EventEmitter[str]()
169+
seen: list[Any] = []
170+
emitter.on("metrics", Stamp())
171+
emitter.on("metrics", Read(seen))
172+
173+
for _ in range(5):
174+
emitter.emit("metrics", Event())
175+
176+
assert seen == ["speech_1"] * 5
177+
178+
179+
def test_off_still_removes_a_handler() -> None:
180+
emitter = EventEmitter[str]()
181+
calls: list[str] = []
182+
183+
@emitter.on("event")
184+
def keep() -> None:
185+
calls.append("keep")
186+
187+
@emitter.on("event")
188+
def drop() -> None:
189+
calls.append("drop")
190+
191+
emitter.off("event", drop)
192+
emitter.off("event", drop) # removing twice must not raise
193+
emitter.emit("event")
194+
assert calls == ["keep"]
195+
196+
197+
def test_registering_the_same_handler_twice_keeps_one_entry() -> None:
198+
emitter = EventEmitter[str]()
199+
calls: list[str] = []
200+
201+
def handler() -> None:
202+
calls.append("x")
203+
204+
emitter.on("event", handler)
205+
emitter.on("event", handler)
206+
emitter.emit("event")
207+
assert calls == ["x"]

0 commit comments

Comments
 (0)