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

  1. Check ws.closed / ws.close_code before sending, or guard sends behind a connection-state flag you control.
  2. Ensure send_str/send_bytes/pong are not called after close(); cancel pending send tasks on close.
  3. Await close() once and stop all senders before/when it begins.
  4. 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

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 cancellation

View on GitHub (pinned to d041d4d0fd)