aio-libs/aiohttp · error · ClientConnectionResetError
Cannot write to closing transport
Error message
Cannot write to closing transport
What it means
Raised as ClientConnectionResetError('Cannot write to closing transport') at the top of WebSocketWriter.send_frame when self._closing is already True and the frame being sent is not a CLOSE frame. The writer marks itself _closing once a close handshake begins; after that only close-completion frames are permitted. Any application send (text/binary/ping) after closing has started is rejected.
Solutions
- Check ws.closed / ws.close_code before sending, or guard sends behind a connection-state flag you control.
- Ensure send_str/send_bytes/pong are not called after close(); cancel pending send tasks on close.
- Await close() once and stop all senders before/when it begins.
- Catch ClientConnectionResetError around non-critical late sends and treat as a clean shutdown.
Example fix
// before
await ws.close()
await ws.send_str('done') // ClientConnectionResetError
// after
if not ws.closed:
await ws.send_str('done')
await ws.close() Defensive patterns
Strategy: validation
Validate before calling
// Guard application sends behind connection state before writing.
if not ws.closed:
await ws.send_str(data)
Type guard
def can_send(ws) -> bool:
return not ws.closed and not getattr(ws, '_closing', False)
Try / catch
try:
await ws.send_str(data)
except ClientConnectionResetError:
# connection is closing; abandon the send
pass
Prevention
- Never call send_str/send_bytes/ping after close().
- Cancel pending send tasks when initiating or detecting close.
- Centralize sends through one function that checks ws.closed first.
When it happens
Trigger: Application code calls ws.send_str()/ws.send_bytes()/ws.ping() after ws.close() has been invoked (or after a close frame was received and the writer set _closing). The check 'self._closing and not (opcode & WSMsgType.CLOSE)' is true.
Common situations: A receive loop detects CLOSED/CLOSING and the send path races to send a final message; cleanup code that sends after close(); a close handshake triggered by the peer while the app is mid-send; firing-and-forgetting sends without checking connection state.
Related errors
AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11).
Data as JSON: /api/errors/19ac3fe2fc963c74.
Report an issue: GitHub.
Appendix: source
Thrown at aiohttp/_websocket/writer.py:78
self.protocol = protocol
self.transport = transport
self.use_mask = use_mask
self.get_random_bits = partial(random.getrandbits, 32)
self.compress = compress
self.notakeover = notakeover
self._closing = False
self._limit = limit
self._output_size = 0
self._compressobj: ZLibCompressor | None = None
self._send_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task[None]] = set()
async def send_frame(
self, message: bytes, opcode: int, compress: int | None = None
) -> None:
"""Send a frame over the websocket with message as its payload."""
if self._closing and not (opcode & WSMsgType.CLOSE):
raise ClientConnectionResetError("Cannot write to closing transport")
if not (compress or self.compress) or opcode >= WS_CONTROL_FRAME_OPCODE:
# Non-compressed frames don't need lock or shield
self._write_websocket_frame(message, opcode, 0)
elif len(message) <= WEBSOCKET_MAX_SYNC_CHUNK_SIZE:
# Small compressed payloads - compress synchronously in event loop
# We need the lock even though sync compression has no await points.
# This prevents small frames from interleaving with large frames that
# compress in the executor, avoiding compressor state corruption.
async with self._send_lock:
self._send_compressed_frame_sync(message, opcode, compress)
else:
# Large compressed frames need shield to prevent corruption
# For large compressed frames, the entire compress+send
# operation must be atomic. If cancelled after compression but
# before send, the compressor state would be advanced but data
# not sent, corrupting subsequent frames.
# Create a task to shield from cancellationView on GitHub (pinned to d041d4d0fd)