WebSocket regression in 3.14.2: compressed data rejected after initial PONG · Issue #13274 · aio-libs/aiohttp · GitHub
Skip to content

WebSocket regression in 3.14.2: compressed data rejected after initial PONG #13274

Closed

Description

@gbtami

Describe the bug

aiohttp 3.14.2/3.14.3 rejects a valid compressed WebSocket data frame when the first inbound frame is a control frame.

The reproducible sequence is:

  1. An aiohttp server sends PING.

  2. The client replies with PONG.

  3. The client sends a compressed TEXT frame using the negotiated permessage-deflate extension.

  4. The aiohttp server closes with WebSocket protocol error 1002:

    Received frame with non-zero reserved bits

The same sequence works correctly with aiohttp 3.14.1.

This appears to be a regression introduced by PR #12988 ("keep websocket compression state across interleaved control frames"). PONG followed by compressed application data is a
valid WebSocket sequence; control frames may be interleaved with data frames under RFC 6455 §5.4.

To Reproduce

import asyncio

  import aiohttp
  from aiohttp import WSMsgType, web


  async def main() -> None:
      pong_received = asyncio.Event()
      server_result = asyncio.get_running_loop().create_future()

      async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
          # Disable server autoping so the reproducer can observe the PONG.
          # Compression remains enabled and is negotiated with the client.
          ws = web.WebSocketResponse(autoping=False, compress=True)
          await ws.prepare(request)

          await ws.ping()
          pong = await ws.receive()
          assert pong.type is WSMsgType.PONG
          pong_received.set()

          message = await ws.receive()
          server_result.set_result((message.type, message.data, ws.exception()))
          await ws.close()
          return ws

      app = web.Application()
      app.router.add_get("/", websocket_handler)
      runner = web.AppRunner(app)
      await runner.setup()

      site = web.TCPSite(runner, "127.0.0.1", 0)
      await site.start()

      try:
          async with aiohttp.ClientSession() as session:
              async with session.ws_connect(
                  f"http://127.0.0.1:{site.port}/",
                  compress=15,
              ) as ws:
                  # receive() processes the server PING, automatically sends
                  # PONG, and then waits for the server's close frame.
                  receive_task = asyncio.create_task(ws.receive())

                  await pong_received.wait()
                  await ws.send_str("hello")

                  message_type, data, exception = await server_result
                  print(f"aiohttp {aiohttp.__version__}")
                  print(f"server message type: {message_type.name}")
                  print(f"server message data: {data!r}")
                  print(f"server exception: {exception!r}")

                  await receive_task
      finally:
          await runner.cleanup()


  asyncio.run(main())


```  Run with:

  $ python -m pip install aiohttp==3.14.3
  $ python repro.py


### Expected behavior

The server should accept and decompress the TEXT message after receiving PONG.

  Expected output, as produced by aiohttp 3.14.1:

  aiohttp 3.14.1
  server message type: TEXT
  server message data: 'hello'
  server exception: None



### Logs/tracebacks

```python-traceback
aiohttp 3.14.3
  server message type: ERROR
  server message data: WebSocketError(<WSCloseCode.PROTOCOL_ERROR: 1002>, 'Received frame with non-zero reserved bits')
  server exception: WebSocketError(<WSCloseCode.PROTOCOL_ERROR: 1002>, 'Received frame with non-zero reserved bits')

Python Version

$ python --version
  Python 3.14.1

aiohttp Version

$ python -m pip show aiohttp
Version: 3.14.3

multidict Version

$ python -m pip show multidict
Version: 6.6.4

propcache Version

$ python -m pip show propcache
Version: 0.3.2

yarl Version

$ python -m pip show yarl
Version: 1.20.1

OS

Ubuntu 26.04 LTS, Linux x86_64, glibc 2.43

Related component

Server

Additional context

Suspected cause:

PR #12988 changed _frame_fin so that only data frames update it, preserving the compression state when a control frame occurs inside a fragmented message.

However, compression-state selection still occurs before that condition. If PONG is the first inbound frame:

  • _compressed is set to COMPRESSED_FALSE from the control frame.
  • _frame_fin remains false because the frame is a control frame.
  • The next compressed data frame has RSV1 set.
  • Since _frame_fin is false and _compressed is no longer COMPRESSED_NOT_SET, the parser rejects RSV1 with protocol error 1002.

The regression is also reproducible with a Chromium browser connecting to an aiohttp server. Pinning aiohttp to 3.14.1 resolves it.

Relevant links:

Code of Conduct

  • I agree to follow the aio-libs Code of Conduct

Activity

  1. added
    regressionSomething that used to work stopped working "as before" after upgrade
    on Jul 27, 2026
  2. waterWang commented on Jul 28, 2026

    @waterWang

    Submitted PR #13277 with the fix. The root cause is that control frames (PONG) were resetting self._compressed to COMPRESSED_FALSE, causing the next compressed data frame to be rejected. The fix wraps the compression state update in an opcode <= 0x7 check so only data frames (TEXT, BINARY, CONTINUATION) can modify the compression state.

  3. added a commit that references this issue on Aug 15, 2026
    b6df59c
  4. added a commit that references this issue on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugregressionSomething that used to work stopped working "as before" after upgrade

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions