aio-libs/aiohttp · error · ClientConnectionResetError

Cannot write to closing transport

Error message

Cannot write to closing transport

What it means

Raised by WebSocketWriter.send_frame (writer.py:78) when self._closing is already True and the opcode being sent is not CLOSE. Once close() has been called, the writer forbids any further application data/control frames, raising ClientConnectionResetError. This prevents writing into a half-closed socket.

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 c0ef574e29)

Solutions

  1. Check ws.closed / ws._closing before sending, or coordinate close with an asyncio.Event so senders stop first.
  2. Guard concurrent close+send with a lock so close wins and senders abort cleanly.
  3. Catch ClientConnectionResetError around sends after a close to tolerate the race.

Example fix

# before
await ws.close()
await ws.send_str('done')  # raises ClientConnectionResetError

# after
if not ws.closed:
    await ws.send_str('done')
await ws.close()
Defensive patterns

Strategy: validation

Validate before calling

if not ws.closed:
    await ws.send_str(payload)

Try / catch

try:
    await ws.send_str(payload)
except aiohttp.ClientConnectionResetError:
    pass  # already closing

Prevention

When it happens

Trigger: Application code calls ws.close() (which sets _closing=True) and then attempts another ws.send_str/ws.send_bytes/ws.ping afterwards; or two tasks race where one closes while the other sends.

Common situations: A receive loop that closes on error and a concurrent sender that has not yet observed the close; cleanup paths that send after close; missing checks of ws.closed.

Related errors


AI-assisted analysis of aio-libs/aiohttp@c0ef574e29 (2026-08-04). Data as JSON: /data/errors/19ac3fe2fc963c74.json. Report an issue: GitHub.