Skip to content

sync client: intermittent handshake timeout with TLS 1.3 session tickets #1779

Closed

Description

@olegrtecno

Checklist

[* ] I searched the FAQ and didn't find an answer.
[* ] I search issues and didn't find an earlier report.
[* ] I personally encountered this problem. I can explain the symptoms and the consequences without relying on an AI coding agent.
[* ] I wrote a short, specific issue description that makes it easy for maintainers to understand the problem and the consequences.
[* ] I am aware that maintainers have AI coding agents. Merely throwing an AI at an issue wastes their time. It comes across as a lack of respect.

Problem

The threading client (websockets.sync.client.connect) sometimes fails the opening handshake against a TLS 1.3 server that sends session tickets, which is the OpenSSL default. The TLS handshake completes, but the server never receives the HTTP upgrade request. The server logs connection closed while reading HTTP request line with 0 bytes read. On the client:

  • usually TimeoutError after open_timeout;
  • sometimes ConnectionClosedError: no close frame received or sent

We hit this in the test suite of a service with mutual-TLS WebSocket listeners. About 1–2% of the threading client's connections failed under load, which made tests fail at random. Our production client uses websockets.asyncio.client and is not affected.

  • Server with ssl_context.num_tickets = 0: no failures. (OP_NO_TICKET is not enough: OpenSSL then still sends tickets, in stateful form.)
  • Client pinned to TLS 1.2: no failures (0 / 1000 in our own setup).
  • asyncio client against the same server: no failures.

Possibly related: AssemblyAI/assemblyai-python-sdk#265 (a workaround in their SDK) and python/cpython#137583 (one thread blocked in SSLSocket.recv() while another sendall()s on the same socket).

Reproduction

Software stack:

  • websockets 17.2
  • Python 3.12.3
  • OpenSSL 3.0.13

The script below produces the following output when ran with 100 attempts:
client=sync tickets=True n=100 failed=10 {'ConnectionClosedError <- SSLEOFError': 1, 'TimeoutError': 9}

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
  -keyout key.pem -out cert.pem -days 2 -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost"
python repro.py 100 sync tickets      # some failures
python repro.py 100 sync notickets    # 0
python repro.py 100 asyncio tickets   # 0
import asyncio, logging, multiprocessing, ssl, sys
from collections import Counter

N, CLIENT, TICKETS = int(sys.argv[1]), sys.argv[2], sys.argv[3] == "tickets"
PORT = 8765

def server(ready):
    logging.getLogger("websockets").setLevel(logging.CRITICAL)
    from websockets.asyncio.server import serve
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.minimum_version = ssl.TLSVersion.TLSv1_3
    ctx.load_cert_chain("cert.pem", "key.pem")
    if not TICKETS:
        ctx.num_tickets = 0
    async def handler(ws):
        await ws.close()
    async def main():
        async with serve(handler, "localhost", PORT, ssl=ctx):
            ready.set()
            await asyncio.Future()
    asyncio.run(main())

def connect_once(uri, ctx):
    if CLIENT == "sync":
        from websockets.sync.client import connect
        with connect(uri, ssl=ctx, open_timeout=5):
            pass
    else:
        from websockets.asyncio.client import connect
        async def go():
            async with connect(uri, ssl=ctx, open_timeout=5):
                pass
        asyncio.run(go())

if __name__ == "__main__":
    ready = multiprocessing.Event()
    p = multiprocessing.Process(target=server, args=(ready,), daemon=True)
    p.start(); ready.wait(10)
    ctx = ssl.create_default_context(cafile="cert.pem")
    failures = Counter()
    for _ in range(N):
        try:
            connect_once(f"wss://localhost:{PORT}/", ctx)
        except Exception as exc:
            cause = exc.__cause__
            failures[type(exc).__name__ + (f" <- {type(cause).__name__}" if cause else "")] += 1
    p.terminate()
    print(f"client={CLIENT} tickets={TICKETS} n={N} failed={sum(failures.values())} {dict(failures)}")

Activity

  1. aaugustin commented on Oct 14, 2026

    @aaugustin
    Member

    Yes, test suites are vulnerable to this because latency is zero on the connection. ctx.num_tickets = 0 is a valid workaround.

    websockets does it in its own test suite:

    # Work around https://github.com/openssl/openssl/issues/7967
    # This bug causes connect() to hang in tests for the client. Including this
    # workaround acknowledges that the issue could happen outside of the test suite.
    # It shouldn't happen too often, or else OpenSSL 1.1.1 would be unusable. If it
    # happens, we can look for a library-level fix, but it won't be easy.
    SERVER_CONTEXT.num_tickets = 0

    The actual upstream issue is openssl/openssl#7967 and OpenSSL maintainers are not interested.

  2. aaugustin commented on Oct 14, 2026

    @aaugustin
    Member

    This problem doesn't happen on real connections because latency is non-zero.

    I don't want to set num_tickets = 0 for everyone; tickets are actually useful.

    People who hit this in their test suites, like you and me, just put the workaround.

  3. olegrtecno commented on Oct 15, 2026

    @olegrtecno
    Author

    Good to know. Thanks!

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions