Skip to content

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

Description

@lesnik512

Summary

ChannelsPlugin.subscribe() lazily creates self._channels[channel] = set() for each new arbitrary channel. ChannelsPlugin.unsubscribe() removes the subscriber from that set and, when the set becomes empty, unsubscribes the backend — but it never deletes the now-empty set() nor its key from self._channels.

With dynamic, per-entity channel names (e.g. one channel per user_id), self._channels therefore grows by one entry for every distinct channel name that has ever been subscribed, and never shrinks. Because the plugin is normally a long-lived singleton, this is an unbounded memory leak that only a process restart clears.

Relevant code

litestar/channels/plugin.py, unsubscribe:

for channel in channels:
    channel_subscribers = self._channels[channel]
    try:
        channel_subscribers.remove(subscriber)
    except KeyError:
        continue
    if not channel_subscribers:
        channels_to_unsubscribe.add(channel)   # backend is unsubscribed...
# ...but self._channels[channel] (now an empty set) is never deleted.

Reproduction

Confirmed on litestar 2.21.1 and on main:

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:
        # at most ONE concurrent subscriber at any moment
        for i in range(1000):
            channel = f"user_{i}"
            subscriber = await plugin.subscribe([channel])
            await plugin.unsubscribe(subscriber, [channel])
        assert len(plugin._channels) == 0, len(plugin._channels)  # AssertionError: 1000


asyncio.run(main())

Expected behavior

After the last subscriber of a channel unsubscribes, the channel entry is removed from self._channels, so the dict size tracks active channels rather than historical ones.

Actual behavior

len(plugin._channels) == 1000 — one empty set() retained per channel ever used. Memory grows unbounded for the process lifetime.

Suggested fix

In unsubscribe(), delete the channel key once its subscriber set is empty, e.g. del self._channels[channel] alongside adding it to channels_to_unsubscribe.

Note

RedisChannelsStreamBackend.stream_events() keeps a stream_ids: dict[str, bytes] cursor map that exhibits the same unbounded-growth pattern (one entry per stream key that ever yielded an event, never pruned). Likely worth fixing in the same pass.

Environment

  • litestar 2.21.1 (also reproduced on main)
  • Python 3.13 / 3.14

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions