Skip to content

Reference cycles keep closed connections alive until a full GC pass #1749

Description

@peteralm80

Closed connections (asyncio, sync, and trio implementations alike) are never
freed by reference counting: two reference cycles keep every connection alive
until the cyclic garbage collector runs. On servers with many connections and
high connect/disconnect churn, this causes very large memory growth, because
CPython 3.13+ collects the oldest generation incrementally and lags far behind
the churn on large heaps.

Observed in production (websockets 15.0.1, CPython 3.14.4, ~450 concurrent
connections with heavy churn): a gc census on the live process showed 13,589
ServerConnection and 3,335 SSLProtocol instances alive with only 453
connections actually open. A forced gc.collect() freed ~1.75 GB — and took
16 seconds, a stop-the-world pause long enough to break the process's other
websocket connections via ping timeout.

Both cycles affect every connection — graceful close and abrupt abort alike.
Verified on 15.0.1 and reproduced on current main.

Cycle 1: LoggerAdapter (all implementations)

Connection.__init__ injects the connection into the protocol's logger:

self.protocol.logger = logging.LoggerAdapter(
    self.protocol.logger,
    {"websocket": self},
)

connection → protocol → logger (LoggerAdapter) → extra dict → connection.
Nothing ever clears it. Disabling logging as documented (NullHandler /
propagate=False / setLevel) does not help — the cycle is created at
construction regardless of logging activity. The same pattern exists in
asyncio/connection.py, sync/connection.py, trio/connection.py (and
legacy/protocol.py, deprecated).

This was suspected once before in #1059, where @aaugustin noted: "I'd be open
to tweaking this with a weakref to facilitate garbage collection."

Cycle 2: cancelled keepalive task (asyncio implementation)

Connection.connection_lost() cancels self.keepalive_task but keeps the
attribute. A cancelled asyncio.Task retains its CancelledError, whose
__traceback__ holds the Connection.keepalive() coroutine frame, whose
locals include self:

connection → task → CancelledError → traceback → frame → connection.

Reproduction

Prints alive: True on current main; only a gc.collect() frees the
connection:

import asyncio, gc, weakref
from websockets.asyncio.client import connect
from websockets.asyncio.server import serve

async def handler(ws):
    try:
        async for _ in ws:
            pass
    except Exception:
        pass

async def main():
    gc.disable()
    gc.collect()
    server = await serve(handler, 'localhost', 8765)
    ws = await connect('ws://localhost:8765')
    await asyncio.sleep(0.2)
    ref = weakref.ref(next(iter(server.connections)))
    ws.transport.abort()          # also reproduces with: await ws.close()
    await asyncio.sleep(0.5)
    print('alive without gc:', ref() is not None)   # True == bug
    server.close()
    await server.wait_closed()

asyncio.run(main())

Break-set experiment on the dead connection (gc still disabled): clearing only
the logger cycle (conn.protocol.logger.extra.clear()) leaves the connection
alive; clearing the logger cycle and dropping the task
(conn.keepalive_task = None) makes the weakref die immediately. Both cycles
must be broken.

Related

recv_exc stores arbitrary exceptions whose tracebacks can also pin frames
referencing the connection (e.g. exceptions raised inside send_context()).
This wasn't required to fix the common paths above, but may be worth
considering separately (exc.__traceback__ = None would keep the cause chain
intact for close_exc.__cause__ while dropping the frames).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions