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).
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
ServerConnectionand 3,335SSLProtocolinstances alive with only 453connections actually open. A forced
gc.collect()freed ~1.75 GB — and took16 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:connection → protocol → logger (LoggerAdapter) →
extradict → 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(andlegacy/protocol.py, deprecated).This was suspected once before in #1059, where @aaugustin noted: "I'd be open
to tweaking this with a
weakrefto facilitate garbage collection."Cycle 2: cancelled keepalive task (asyncio implementation)
Connection.connection_lost()cancelsself.keepalive_taskbut keeps theattribute. A cancelled
asyncio.Taskretains itsCancelledError, whose__traceback__holds theConnection.keepalive()coroutine frame, whoselocals include
self:connection → task → CancelledError → traceback → frame → connection.
Reproduction
Prints
alive: Trueon current main; only agc.collect()frees theconnection:
Break-set experiment on the dead connection (gc still disabled): clearing only
the logger cycle (
conn.protocol.logger.extra.clear()) leaves the connectionalive; clearing the logger cycle and dropping the task
(
conn.keepalive_task = None) makes the weakref die immediately. Both cyclesmust be broken.
Related
recv_excstores arbitrary exceptions whose tracebacks can also pin framesreferencing 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__ = Nonewould keep the cause chainintact for
close_exc.__cause__while dropping the frames).