Skip to content

Break reference cycles keeping closed connections alive - #1750

Open
peteralm80 wants to merge 2 commits into
python-websockets:mainfrom
peteralm80:fix-connection-reference-cycles
Open

Break reference cycles keeping closed connections alive#1750
peteralm80 wants to merge 2 commits into
python-websockets:mainfrom
peteralm80:fix-connection-reference-cycles

Conversation

@peteralm80

Copy link
Copy Markdown

Fixes #1749.

Closed connections could only be freed by the cyclic garbage collector, never
by reference counting, because of two reference cycles (details, production
numbers, and reproduction in the issue):

  1. The LoggerAdapter that injects the websocket attribute into log records
    held a strong reference to the connection (all implementations).
  2. In the asyncio implementation, the cancelled keepalive task retained a
    CancelledError whose traceback references the keepalive() frame and
    thus the connection.

Changes:

  • New ConnectionLoggerAdapter in websockets/utils.py holds a weak
    reference to the connection and injects it into log records at logging time
    (the weakref approach suggested in Reference cycle in websockets 10? #1059). Behavior is unchanged for any
    record emitted while the connection object is alive — including "connection
    handler failed" in the server, since the handler still references the
    connection at that point — because each record receives its own strong
    reference via extra. The documented adapter-wrapping pattern
    (kwargs["extra"]["websocket"] with except KeyError) keeps working.
    Used by the asyncio, sync, and trio implementations. The deprecated legacy
    implementation is left untouched.

  • Connection.connection_lost() (asyncio) dereferences keepalive_task after
    cancelling it, so the task, its CancelledError, and the pinned frame die
    by refcount once the event loop finishes the cancellation.

Tests:

  • Regression tests in tests/asyncio/test_connection.py (graceful close and
    abort) and tests/sync/test_connection.py assert with gc.disable() in
    effect that a weakref to a closed connection dies — i.e. the connection is
    freed by reference counting alone. They fail without the fix and are skipped
    on non-CPython implementations.
  • Unit tests for ConnectionLoggerAdapter in tests/test_utils.py.
  • Two existing keepalive tests that read keepalive_task after close now
    capture the task before closing.

🤖 Generated with Claude Code

Peter Alm added 2 commits August 21, 2026 10:25
Closed connections could only be freed by the cyclic garbage collector,
never by reference counting, because reference cycles referenced the
connection:

* The LoggerAdapter that injects the websocket attribute into log
  records held a strong reference to the connection, in all
  implementations: connection -> protocol -> logger -> extra dict ->
  connection.

* In the asyncio implementation, the keepalive task retained the
  CancelledError raised when connection_lost() cancelled it, whose
  traceback references the keepalive() frame and thus the connection:
  connection -> task -> exception -> traceback -> frame -> connection.

* In the trio implementation, the assembler held bound methods of the
  connection for flow control: connection -> recv_messages -> pause()
  and resume() callbacks -> connection.

On servers handling many connections with high connect/disconnect
churn, closed connections accumulated until a full collection of the
oldest generation, which can lag far behind and pause the event loop
for several seconds on large heaps, especially since CPython 3.13
collects the oldest generation incrementally.

ConnectionLoggerAdapter now holds a weak reference to the connection
and injects it into log records only while the connection is alive;
records created while the connection is alive keep their own strong
reference, so logging filters and handlers are unaffected.
connection_lost() dereferences the keepalive task after cancelling it.
The assembler drops the flow control callbacks when it's closed.

Garbage collection tests are skipped on Python 3.12, where the
traceback of an exception raised while closing a connection keeps
frames of connection methods alive via their f_back attribute.
Python 3.13 fixed that behavior.
When recv_exc is raised in a method of the connection, its traceback
references frames of connection methods, which reference the
connection: connection -> recv_exc -> traceback -> frame -> connection.
This prevents reference counting from freeing closed connections. It
happens on any read error in the sync and trio implementations, whose
recv_events() is a method of the connection, and on write errors while
responding to incoming frames in all implementations.

Clear the frames of recv_exc's traceback once they finished running:
after connection_lost() in the asyncio implementation and after
recv_events() terminates in the sync and trio implementations. Clearing
frames doesn't affect formatting the traceback.

In the sync implementation, the thread running recv_events() must also
dereference the connection when it terminates because the traceback of
recv_exc keeps a reference to its frame via the f_back attribute of the
recv_events() frame.
@peteralm80
peteralm80 force-pushed the fix-connection-reference-cycles branch from 8b14445 to e5ebdf4 Compare August 21, 2026 08:29
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.

Reference cycles keep closed connections alive until a full GC pass

2 participants