From 02df33266349b3cfb56280ff8bfed3ee540c3b2d Mon Sep 17 00:00:00 2001 From: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:34:55 +0530 Subject: [PATCH 1/3] fix(rtc): pass once() handlers the arguments they take, and let off() remove them once() registered a *args wrapper, so emit() passed it every argument and a handler taking fewer raised TypeError (re-raised by emit), where on() drops the extra ones. off(event, callback) also could not remove a handler added with once(event, callback). The wrapper now carries the callback's signature and a reference to it. --- livekit-rtc/livekit/rtc/event_emitter.py | 14 ++++++- tests/rtc/test_emitter.py | 48 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 38f54901..9b32eaa1 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -120,6 +120,14 @@ def once_callback(*args: Any, **kwargs: Any) -> None: self.off(event, once_callback) callback(*args, **kwargs) + # emit() reads the signature to pass only the arguments the callback takes, + # as it does for on(); without one it passes them all + try: + once_callback.__signature__ = inspect.signature(callback) # type: ignore[attr-defined] + except (TypeError, ValueError): + pass + # lets off() remove the handler by the callback it was registered with + once_callback._once_callback = callback # type: ignore[attr-defined] return self.on(event, once_callback) else: @@ -212,4 +220,8 @@ def greet(name): ``` """ if event in self._events: - self._events[event].pop(callback, None) + handlers = self._events[event] + handlers.pop(callback, None) + once_handlers = [h for h in handlers if getattr(h, "_once_callback", None) == callback] + for handler in once_handlers: + handlers.pop(handler) diff --git a/tests/rtc/test_emitter.py b/tests/rtc/test_emitter.py index 4f67f76a..f066423c 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -287,3 +287,51 @@ def handler() -> None: emitter.on("event", handler) emitter.emit("event") assert calls == ["x"] + + +def test_once_passes_only_the_arguments_the_callback_takes() -> None: + emitter = EventEmitter[str]() + calls: list[Any] = [] + + @emitter.once("event") + def on_event(first: Any) -> None: + calls.append(first) + + emitter.emit("event", 1, 2, 3) # like on(), the extra arguments are dropped + emitter.emit("event", 4) + + assert calls == [1] + + +def test_once_runs_a_callback_without_a_signature() -> None: + emitter = EventEmitter[str]() + results: list[Any] = [] + + emitter.once("event", min) # builtins like min expose no signature + emitter.once("event", lambda *args: results.append(args)) + emitter.emit("event", 2, 1) + emitter.emit("event", 3, 4) + + assert results == [(2, 1)] + assert not emitter._events["event"] + + +def test_off_removes_a_once_handler() -> None: + emitter = EventEmitter[str]() + calls: list[str] = [] + + def handler() -> None: + calls.append("x") + + class Listener: + def handle(self) -> None: + calls.append("method") + + listener = Listener() + emitter.once("event", handler) + emitter.once("event", listener.handle) + emitter.off("event", handler) + emitter.off("event", listener.handle) # a new bound method object, equal to the first + emitter.emit("event") + + assert calls == [] From 404d2ad6d37c8911f19fe9ded82425484e8a5f6a Mon Sep 17 00:00:00 2001 From: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:58:19 +0530 Subject: [PATCH 2/3] fix(rtc): read a once() callback's signature at emit time, match it in off() like a dict key emit() now takes the signature from the registered callback when it fires, so a functools.partial changed after once() is trimmed like an on() handler. off() compares hashes before calling ==, so removing an unrelated handler never calls a once() callback's __eq__. --- livekit-rtc/livekit/rtc/event_emitter.py | 38 +++++++++++++++++------ tests/rtc/test_emitter.py | 39 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 9b32eaa1..899fedd9 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -16,6 +16,31 @@ T_contra = TypeVar("T_contra", contravariant=True) +def _signature(handler: Callable) -> inspect.Signature: + """Signature emit() trims the arguments to: a once() handler's callback's, read at call time.""" + callback = getattr(handler, "_once_callback", None) + if callback is not None: + try: + return inspect.signature(callback) + except (TypeError, ValueError): + pass # no signature (e.g. a builtin): the wrapper's *args passes everything + return inspect.signature(handler) + + +def _is_once_handler_for(handler: Callable, callback: Callable) -> bool: + """Whether handler is a once() wrapper of callback, matched the way a dict key is.""" + target = getattr(handler, "_once_callback", None) + if target is None: + return False + if target is callback: + return True + try: + # compare hashes first, so an unrelated callback's __eq__ is never called + return hash(target) == hash(callback) and bool(target == callback) + except TypeError: # unhashable + return False + + class EventEmitter(Generic[T_contra]): def __init__(self) -> None: """ @@ -51,7 +76,7 @@ def greet(name): callables = list(self._events[event]) for callback in callables: try: - sig = inspect.signature(callback) + sig = _signature(callback) params = sig.parameters.values() has_varargs = any(p.kind == p.VAR_POSITIONAL for p in params) @@ -120,13 +145,8 @@ def once_callback(*args: Any, **kwargs: Any) -> None: self.off(event, once_callback) callback(*args, **kwargs) - # emit() reads the signature to pass only the arguments the callback takes, - # as it does for on(); without one it passes them all - try: - once_callback.__signature__ = inspect.signature(callback) # type: ignore[attr-defined] - except (TypeError, ValueError): - pass - # lets off() remove the handler by the callback it was registered with + # emit() trims the arguments to the callback's signature, as for on(), and + # off() removes the handler by the callback it was registered with once_callback._once_callback = callback # type: ignore[attr-defined] return self.on(event, once_callback) else: @@ -222,6 +242,6 @@ def greet(name): if event in self._events: handlers = self._events[event] handlers.pop(callback, None) - once_handlers = [h for h in handlers if getattr(h, "_once_callback", None) == callback] + once_handlers = [h for h in handlers if _is_once_handler_for(h, callback)] for handler in once_handlers: handlers.pop(handler) diff --git a/tests/rtc/test_emitter.py b/tests/rtc/test_emitter.py index f066423c..245497d2 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -335,3 +335,42 @@ def handle(self) -> None: emitter.emit("event") assert calls == [] + + +def test_once_trims_to_the_callback_signature_at_emit_time() -> None: + emitter = EventEmitter[str]() + calls: list[tuple[Any, Any]] = [] + + def handler(a: Any, b: Any) -> None: + calls.append((a, b)) + + callback = functools.partial(handler, b="registered") + emitter.once("event", callback) + callback.keywords.clear() # a partial's signature can change after registration + emitter.emit("event", 1, 2) + + assert calls == [(1, 2)] + + +def test_off_does_not_compare_unrelated_once_callbacks() -> None: + emitter = EventEmitter[str]() + + class Strict: + def __call__(self) -> None: + pass + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Strict): + raise ValueError("compared with an unrelated callable") + return other is self + + __hash__ = object.__hash__ + + def plain() -> None: + pass + + emitter.once("event", Strict()) + emitter.on("event", plain) + emitter.off("event", plain) + + assert len(emitter._events["event"]) == 1 From 5f37335e32972ecde6857ec802a6bea59cc9d5e9 Mon Sep 17 00:00:00 2001 From: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:58:26 +0530 Subject: [PATCH 3/3] fix(rtc): mark once() handlers by type instead of an attribute once() now registers a private _OnceHandler, so emit() and off() recognise it with isinstance. An on() handler that happens to carry a _once_callback attribute keeps its own signature. --- livekit-rtc/livekit/rtc/event_emitter.py | 34 ++++++++++++++---------- tests/rtc/test_emitter.py | 14 ++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/livekit-rtc/livekit/rtc/event_emitter.py b/livekit-rtc/livekit/rtc/event_emitter.py index 899fedd9..39364ea9 100644 --- a/livekit-rtc/livekit/rtc/event_emitter.py +++ b/livekit-rtc/livekit/rtc/event_emitter.py @@ -16,22 +16,34 @@ T_contra = TypeVar("T_contra", contravariant=True) +class _OnceHandler: + """The handler once() registers: removes itself, then calls the callback.""" + + def __init__(self, emitter: "EventEmitter[Any]", event: Any, callback: Callable) -> None: + self._emitter = emitter + self._event = event + self.callback = callback + + def __call__(self, *args: Any, **kwargs: Any) -> None: + self._emitter.off(self._event, self) + self.callback(*args, **kwargs) + + def _signature(handler: Callable) -> inspect.Signature: """Signature emit() trims the arguments to: a once() handler's callback's, read at call time.""" - callback = getattr(handler, "_once_callback", None) - if callback is not None: + if isinstance(handler, _OnceHandler): try: - return inspect.signature(callback) + return inspect.signature(handler.callback) except (TypeError, ValueError): - pass # no signature (e.g. a builtin): the wrapper's *args passes everything + pass # no signature (e.g. a builtin): the handler's *args passes everything return inspect.signature(handler) def _is_once_handler_for(handler: Callable, callback: Callable) -> bool: - """Whether handler is a once() wrapper of callback, matched the way a dict key is.""" - target = getattr(handler, "_once_callback", None) - if target is None: + """Whether handler is a once() handler of callback, matched the way a dict key is.""" + if not isinstance(handler, _OnceHandler): return False + target = handler.callback if target is callback: return True try: @@ -140,15 +152,9 @@ def greet_once(name): ``` """ if callback is not None: - - def once_callback(*args: Any, **kwargs: Any) -> None: - self.off(event, once_callback) - callback(*args, **kwargs) - # emit() trims the arguments to the callback's signature, as for on(), and # off() removes the handler by the callback it was registered with - once_callback._once_callback = callback # type: ignore[attr-defined] - return self.on(event, once_callback) + return self.on(event, _OnceHandler(self, event, callback)) else: def decorator(callback: Callable) -> Callable: diff --git a/tests/rtc/test_emitter.py b/tests/rtc/test_emitter.py index 245497d2..289875bb 100644 --- a/tests/rtc/test_emitter.py +++ b/tests/rtc/test_emitter.py @@ -374,3 +374,17 @@ def plain() -> None: emitter.off("event", plain) assert len(emitter._events["event"]) == 1 + + +def test_an_on_handler_with_a_once_callback_attribute_keeps_its_signature() -> None: + emitter = EventEmitter[str]() + calls: list[Any] = [] + + def handler(value: Any) -> None: + calls.append(value) + + handler._once_callback = lambda: None # type: ignore[attr-defined] + emitter.on("event", handler) + emitter.emit("event", 1) + + assert calls == [1]