From 16dceb0d2d67027f44284cab263e1817abde9d08 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:27:13 +0000 Subject: [PATCH 1/2] fix(rtc): make EventEmitter.once callbacks honor off() and argument trimming --- livekit-rtc/livekit/rtc/event_emitter.py | 13 ++++- tests/rtc/test_emitter.py | 72 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 38f54901..55498219 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -120,6 +120,9 @@ def once_callback(*args: Any, **kwargs: Any) -> None: self.off(event, once_callback) callback(*args, **kwargs) + # `inspect.signature` follows `__wrapped__`, so `emit` trims the arguments to + # what `callback` accepts, and `off` can match the wrapper to `callback`. + once_callback.__wrapped__ = callback # type: ignore[attr-defined] return self.on(event, once_callback) else: @@ -212,4 +215,12 @@ def greet(name): ``` """ if event in self._events: - self._events[event].pop(callback, None) + handlers = self._events[event] + if callback in handlers: + del handlers[callback] + return + # a callback registered with `once` is stored wrapped + for registered in handlers: + if getattr(registered, "__wrapped__", None) == callback: + del handlers[registered] + return diff --git a/tests/rtc/test_emitter.py b/tests/rtc/test_emitter.py index 4f67f76a..aabec6f6 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -287,3 +287,75 @@ def handler() -> None: emitter.on("event", handler) emitter.emit("event") assert calls == ["x"] + + +def test_off_removes_a_once_callback() -> None: + emitter = EventEmitter[str]() + calls: list[bool] = [] + + def on_event() -> None: + calls.append(True) + + emitter.once("event", on_event) + emitter.off("event", on_event) + emitter.emit("event") + + assert calls == [] + + +def test_off_removes_a_once_bound_method() -> None: + class Handler: + def __init__(self) -> None: + self.calls = 0 + + def on_event(self) -> None: + self.calls += 1 + + emitter = EventEmitter[str]() + handler = Handler() + + emitter.once("event", handler.on_event) + emitter.off("event", handler.on_event) + emitter.emit("event") + + assert handler.calls == 0 + + +def test_once_passes_only_the_arguments_the_callback_accepts() -> None: + emitter = EventEmitter[str]() + calls: list[tuple[Any, ...]] = [] + + def on_event(first: Any, second: Any) -> None: + calls.append((first, second)) + + emitter.once("event", on_event) + emitter.emit("event", 1, 2, 3, 4) # only 2 arguments will be passed + emitter.emit("event", 1, 2, 3, 4) + + assert calls == [(1, 2)] + + +def test_once_with_no_parameters_ignores_emitted_arguments() -> None: + emitter = EventEmitter[str]() + calls: list[bool] = [] + + @emitter.once("event") + def on_event() -> None: + calls.append(True) + + emitter.emit("event", 1, 2) + + assert calls == [True] + + +def test_once_passes_all_arguments_to_a_varargs_callback() -> None: + emitter = EventEmitter[str]() + calls: list[tuple[Any, ...]] = [] + + @emitter.once("event") + def on_event(*args: Any) -> None: + calls.append(args) + + emitter.emit("event", 1, 2, 3) + + assert calls == [(1, 2, 3)] From 0840783325894aaf1ca2e8151ed1d699121fc977 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:20:53 +0000 Subject: [PATCH 2/2] fix(rtc): scope EventEmitter.off once-wrapper lookup to once registrations --- livekit-rtc/livekit/rtc/event_emitter.py | 16 +++++------- tests/rtc/test_emitter.py | 33 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 55498219..65a14990 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -121,8 +121,10 @@ def once_callback(*args: Any, **kwargs: Any) -> None: callback(*args, **kwargs) # `inspect.signature` follows `__wrapped__`, so `emit` trims the arguments to - # what `callback` accepts, and `off` can match the wrapper to `callback`. + # what `callback` accepts. `_once_of` is set only on these wrappers, so `off` + # can match them to `callback` without touching other decorated listeners. once_callback.__wrapped__ = callback # type: ignore[attr-defined] + once_callback._once_of = callback # type: ignore[attr-defined] return self.on(event, once_callback) else: @@ -216,11 +218,7 @@ def greet(name): """ if event in self._events: handlers = self._events[event] - if callback in handlers: - del handlers[callback] - return - # a callback registered with `once` is stored wrapped - for registered in handlers: - if getattr(registered, "__wrapped__", None) == callback: - del handlers[registered] - return + handlers.pop(callback, None) + # a callback registered with `once` is stored wrapped, possibly more than once + for registered in [h for h in handlers if getattr(h, "_once_of", None) == callback]: + del handlers[registered] diff --git a/tests/rtc/test_emitter.py b/tests/rtc/test_emitter.py index aabec6f6..ee0582fb 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -359,3 +359,36 @@ def on_event(*args: Any) -> None: emitter.emit("event", 1, 2, 3) assert calls == [(1, 2, 3)] + + +def test_off_keeps_a_wraps_decorated_listener_of_another_callback() -> None: + emitter = EventEmitter[str]() + calls: list[bool] = [] + + def original() -> None: + pass + + @functools.wraps(original) + def listener() -> None: + calls.append(True) + + emitter.on("event", listener) + emitter.off("event", original) + emitter.emit("event") + + assert calls == [True] + + +def test_off_removes_every_once_registration_of_a_callback() -> None: + emitter = EventEmitter[str]() + calls: list[bool] = [] + + def handler() -> None: + calls.append(True) + + emitter.once("event", handler) + emitter.once("event", handler) + emitter.off("event", handler) + emitter.emit("event") + + assert calls == []