Skip to content

fix(rtc): make EventEmitter.once callbacks honor off() and argument trimming - #834

Open
RaphaelFakhri wants to merge 2 commits into
livekit:mainfrom
RaphaelFakhri:fix/emitter-once-off-and-args
Open

RaphaelFakhri wants to merge 2 commits into
livekit:mainfrom
RaphaelFakhri:fix/emitter-once-off-and-args

Conversation

@RaphaelFakhri

Copy link
Copy Markdown

Summary

EventEmitter.once registers an internal wrapper instead of the callback you pass in. This causes two bugs that EventEmitter.on does not have:

  • off(event, callback) does not remove a callback registered with once, so the callback still runs on the next emit.
  • emit passes every argument to a once callback. A callback that accepts fewer positional parameters than the number of emitted arguments raises TypeError. on callbacks get their arguments trimmed to the parameters they accept.

Changes

  • once sets __wrapped__ on the wrapper. inspect.signature follows it, so emit trims arguments to the signature of the original callback.
  • off falls back to matching a wrapper whose __wrapped__ equals the callback, which also covers bound methods.
  • once still returns the value of on, so existing callers see no change.

Testing

Added five tests to tests/rtc/test_emitter.py:

  • off removes a once callback (function and bound method).
  • once trims arguments for a callback with fewer parameters than emitted arguments, and for a callback with none.
  • once still passes all arguments to a *args callback.

Without the change to event_emitter.py, 4 of the 17 tests in test_emitter.py fail. With the change, all 17 pass. ruff format --check and mypy are clean for the changed files.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +224 to +225
if getattr(registered, "__wrapped__", None) == callback:
del handlers[registered]

@devin-ai-integration devin-ai-integration Bot Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Decorated listener lost during cancellation

If a listener wraps the return value of once, functools.wraps copies _once_of onto it. off then removes that listener with the original callback, even though it was registered separately through on.

Learn more

once returns its registered wrapper, and functools.wraps copies that wrapper's attribute dictionary into another function. The new function therefore inherits _once_of even though it was registered through on. When off searches by this attribute, it removes both functions.

Example: Register wrapped = emitter.once('event', original), then register @functools.wraps(wrapped) as an on('event') listener. Calling emitter.off('event', original) removes both registrations; the on listener no longer receives events.

Recommended fix: Track once registrations outside the callback's copyable attributes, such as in emitter-owned wrapper-to-original metadata. Update the mapping when wrappers are removed and preserve support for repeated registrations and bound methods.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread livekit-rtc/livekit/rtc/event_emitter.py Outdated
@CLAassistant

CLAassistant commented Sep 29, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@RaphaelFakhri

Copy link
Copy Markdown
Author

Unrelated decorated listener removed by off

Valid finding, fixed in the new commit. once now tags its wrapper with a private _once_of attribute, and off matches only wrappers that carry it. It no longer reads __wrapped__ on arbitrary listeners, so a functools.wraps-decorated listener registered with on stays registered when off receives the original function. A regression test covers this case.

Duplicate once listener survives removal

Valid finding, fixed in the same commit. off now collects every once wrapper that belongs to the callback before deleting them, so registering the same callback with once twice and calling off once removes both. Direct removal for on listeners is unchanged. A regression test covers this case.

Generating protobuf check

The failure is unrelated to the change. The job runs actions/checkout with ref: github.event.pull_request.head.ref in the base repository, and a pull request from a fork has no such branch there, so the fetch fails before any step runs. The other fork pull requests to this repository fail the same way.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants