Skip to content

[Bug] Collaboration rooms are never cleaned up after all users disconnect and reconnect - abandoned rooms with no sockets leak in CollaborationManager.rooms indefinitely #1129

Description

@rachel-d-07

## Summary
In backend/app/routers/collaboration.py, the CollaborationManager.disconnect() method deletes a room from self.rooms only when the last user disconnects cleanly via WebSocketDisconnect. However, when clients drop abruptly (network failure, tab close, browser crash), their sockets become stale and are only removed lazily inside broadcast() via the stale_clients cleanup path. If all clients in a room drop abruptly without anyone sending a message that triggers a broadcast, the room is never deleted — it stays in self.rooms forever with an empty sockets dict but retaining its full code, comments, and users state in memory.


## Steps To Reproduce

  1. Start the backend locally.
  2. Connect two clients to WS /ws/test-room.
  3. Kill both clients abruptly (close the browser tab or kill the process) without sending any further messages.
  4. No broadcast is triggered, so stale_clients cleanup in broadcast() never runs.
  5. Inspect manager.rooms - test-room still exists with empty sockets and users but retains all its code and comments in memory.
  6. Repeat with many unique session IDs -manager.rooms grows without bound, leaking memory for the lifetime of the server process.

## Expected Behavior
Rooms with no active sockets should be deleted from manager.rooms promptly. Either:

  • A periodic background task should sweep manager.rooms and remove any room where len(room.sockets) == 0, or
  • The stale socket cleanup path inside broadcast() should also trigger a room deletion check after removing the last client.

## Actual Behavior
The disconnect() method correctly deletes a room on a clean disconnect only when should_delete is True:

# backend/app/routers/collaboration.py - disconnect()

async with room.lock:
    room.sockets.pop(client_id, None)
    room.users.pop(client_id, None)
    users = self._users_payload(room)
    should_delete = not room.sockets   # ← True only on clean WebSocketDisconnect

if should_delete:
    self.rooms.pop(session_id, None)   # ← never reached on abrupt disconnect
    return

But the stale client cleanup inside broadcast() calls disconnect() per stale client — which itself only deletes the room if should_delete is True after each removal. If all clients are stale and drop simultaneously without a broadcast being triggered, disconnect() is never called at all:

# backend/app/routers/collaboration.py — broadcast()

stale_clients: list[str] = []

for client_id, socket in list(room.sockets.items()):
    try:
        await socket.send_json(message)
    except RuntimeError:
        stale_clients.append(client_id)   # ← detected only during a broadcast

for client_id in stale_clients:
    await self.disconnect(session_id, client_id)  # ← never called if no broadcast fires

So if all clients in a room drop silently with no subsequent broadcast, the room leaks permanently with empty sockets and users but full code and comments retained.


## Expected Fix
Add a periodic sweep task to scheduler.py that removes empty rooms:

def _purge_empty_collab_rooms() -> None:
    from ..routers.collaboration import manager
    empty = [sid for sid, room in list(manager.rooms.items()) if not room.sockets]
    for sid in empty:
        manager.rooms.pop(sid, None)
    if empty:
        log.info("Purged %d empty collaboration room(s)", len(empty))

And register it in start_scheduler():

scheduler.add_job(
    _purge_empty_collab_rooms,
    trigger="interval",
    minutes=30,
    id="purge_empty_collab_rooms",
    replace_existing=True,
)

## Environment

  • OS: Linux (backend/server-side bug — not OS-specific)
  • Browser (if frontend): N/A
  • Python version (if backend): 3.12+

## Logs/Screenshots
No runtime logs needed — the leak is confirmed by reading the code. Verified with:

grep -n "should_delete\|stale_clients\|rooms.pop\|disconnect" backend/app/routers/collaboration.py
# should_delete — only set True on clean WebSocketDisconnect path
# stale_clients — only cleaned up if a broadcast fires
# rooms.pop     — never called if no broadcast triggers after abrupt drop

I would like to work on this issue as part of GSSoC '26.Could you please assign this issue to me

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions