diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 38f54901..65a14990 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -120,6 +120,11 @@ 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. `_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: @@ -212,4 +217,8 @@ def greet(name): ``` """ if event in self._events: - self._events[event].pop(callback, None) + handlers = self._events[event] + 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 4f67f76a..ee0582fb 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -287,3 +287,108 @@ 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)] + + +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 == []