Skip to content

fix(channels): remove empty channel entries on unsubscribe to prevent memory leak#4868

Open
lesnik512 wants to merge 6 commits into
litestar-org:mainfrom
lesnik512:fix/channels-plugin-unsubscribe-leak
Open

fix(channels): remove empty channel entries on unsubscribe to prevent memory leak#4868
lesnik512 wants to merge 6 commits into
litestar-org:mainfrom
lesnik512:fix/channels-plugin-unsubscribe-leak

Conversation

@lesnik512

@lesnik512 lesnik512 commented Jun 19, 2026

Copy link
Copy Markdown

Description

ChannelsPlugin._channels grows without bound when the plugin is used with
arbitrary_channels_allowed=True and dynamic channel names (e.g. one channel per user),
because unsubscribe() removed the subscriber and unsubscribed the backend but never deleted
the now-empty set() nor its key from self._channels. For long-lived applications this is a
memory leak that only a process restart clears.

This PR removes a dynamically created channel from _channels once its last subscriber leaves.
Channels declared in the constructor (channels=[...]) are preserved, since they back the
generated WebSocket route handlers and the startup backend subscription.

Reproduction (before this PR)

import asyncio
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend

async def main() -> None:
    plugin = ChannelsPlugin(backend=MemoryChannelsBackend(history=10), arbitrary_channels_allowed=True)
    async with plugin:
        for i in range(1000):
            s = await plugin.subscribe([f"user_{i}"])
            await plugin.unsubscribe(s, [f"user_{i}"])
        assert len(plugin._channels) == 0  # fails: 1000

asyncio.run(main())

Changes

  • ChannelsPlugin records constructor-declared channels and, in unsubscribe(), deletes a
    channel key once its subscriber set is empty only for dynamically created channels.
  • Added tests: arbitrary channel pruned after last subscriber, declared channel kept, and a
    1000-cycle no-leak check. Existing unsubscribe tests use declared channels and are unaffected.

Notes

RedisChannelsStreamBackend.stream_events() keeps a stream_ids cursor map that exhibits the
same unbounded-growth pattern; happy to address it here in a follow-up commit or a separate PR —
let me know your preference.

Closes

Closes #4867

🤖 Generated with Claude Code


📚 Documentation preview 📚: https://litestar-org.github.io/litestar-docs-preview/4868

ChannelsPlugin.unsubscribe removed the subscriber and unsubscribed the
backend but left the now-empty set and its key in self._channels. With
dynamic per-entity channel names this grew unbounded for the process
lifetime. Dynamically created channels are now deleted once their last
subscriber leaves; constructor-declared channels are preserved.

Closes litestar-org#4867

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lesnik512
lesnik512 marked this pull request as ready for review June 19, 2026 05:45
@lesnik512
lesnik512 requested review from a team as code owners June 19, 2026 05:45
@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.29%. Comparing base (64aa847) to head (6fd0f41).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4868   +/-   ##
=======================================
  Coverage   67.28%   67.29%           
=======================================
  Files         293      293           
  Lines       15226    15229    +3     
  Branches     1727     1728    +1     
=======================================
+ Hits        10245    10248    +3     
  Misses       4834     4834           
  Partials      147      147           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread litestar/channels/plugin.py Outdated
lesnik512 and others added 2 commits July 1, 2026 16:51
Arbitrary channels are deleted from ``_channels`` once their last subscriber
leaves. A second ``unsubscribe`` for the same channel then indexed the missing
entry and raised ``KeyError``. Use ``dict.get`` with an empty-set default so the
existing "not subscribed" branch handles it instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lesnik512
lesnik512 requested a review from Vitaly312 July 1, 2026 13:56
@Vitaly312

Copy link
Copy Markdown
Contributor

Thanks! The code is fine, just a little nit: comments look a bit verbose, I think we can trim them since the code isn't complicated enough. What do you think?

@lesnik512

Copy link
Copy Markdown
Author

Thanks! The code is fine, just a little nit: comments look a bit verbose, I think we can trim them since the code isn't complicated enough. What do you think?

I've compacted and removed some comments, please take a loog

Comment thread litestar/channels/plugin.py Outdated
Co-authored-by: Vitaly <128831423+Vitaly312@users.noreply.github.com>

@Vitaly312 Vitaly312 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.

Thanks. CI failure is unrelated to the changes(looks like some flaky test)

@lesnik512
lesnik512 requested a review from Vitaly312 July 25, 2026 04:59

@Vitaly312 Vitaly312 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.

Approved (again?). Anyway, I don't have write access :)

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.

Bug: ChannelsPlugin.unsubscribe leaks empty channel entries with arbitrary_channels_allowed=True

2 participants