Skip to content

sync: close() can hang forever on Linux when the peer stalls mid TLS record (shutdown() dropped in 17) #1787

Closed

Description

@MichaelAshley2000

Version: websockets 17.4 (16.0 is fine), Python 3.13, Linux. macOS doesn't reproduce this, because closing a socket there wakes a thread blocked in recv().

What happens: with the sync client, if the peer sends the first bytes of a TLS record and then stalls, leaving the connection blocks forever. close() (e.g. on leaving with connect(...)) sends the close frame and waits close_timeout. It then calls close_socket() and recv_events_thread.join() with no timeout. The join never returns.

Why: recv_events() was woken by poller.select() because the socket was readable, and is now blocked in SSLSocket.recv() waiting for the rest of the record. In 16.0, close_socket() did

# shutdown() is required to interrupt recv() on Linux.
self.socket.shutdown(socket.SHUT_RDWR)
self.socket.close()

Since #1778, close_socket() signals shutdown_notifier and calls socket.close(), but no longer shutdown(). The notifier only helps while the reader is in select(). A recv() already in progress isn't interrupted, and on Linux close() from another thread doesn't wake it either. The same thing can happen in connect() when the handshake times out: open_timeout expires, then close_socket() and join().

Repro (standalone; needs openssl on PATH). The server completes the upgrade, then sends only the 5-byte header of a TLS record:

"""websockets 17 sync client: close() hangs forever on Linux when the peer
stalls mid TLS record. 16.0 shut the socket down in close_socket(); 17 does not."""
import base64, hashlib, os, socket, ssl, subprocess, sys, tempfile, threading, time
from websockets.sync.client import connect

d = tempfile.mkdtemp()
cert, key = os.path.join(d, "c.pem"), os.path.join(d, "k.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
                "-keyout", key, "-out", cert, "-subj", "/CN=localhost",
                "-addext", "subjectAltName=DNS:localhost"], check=True, capture_output=True)
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(cert, key)
listener = socket.create_server(("127.0.0.1", 0))
port = listener.getsockname()[1]

def serve():
    client, _ = listener.accept()
    tls = server_ctx.wrap_socket(client, server_side=True)
    req = b""
    while b"\r\n\r\n" not in req:
        req += tls.recv(4096)
    k = [l.split(b":", 1)[1].strip() for l in req.split(b"\r\n") if l.lower().startswith(b"sec-websocket-key:")][0]
    accept = base64.b64encode(hashlib.sha1(k + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest())
    tls.sendall(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n"
                b"Connection: Upgrade\r\nSec-WebSocket-Accept: " + accept + b"\r\n\r\n")
    os.write(tls.fileno(), b"\x17\x03\x03\x00\x40")  # header of a 64-byte record, no body
    time.sleep(3600)

threading.Thread(target=serve, daemon=True).start()
ctx = ssl.create_default_context(cafile=cert)

def client():
    sock = socket.create_connection(("127.0.0.1", port))
    with connect(f"wss://localhost:{port}/", sock=sock, ssl=ctx, server_hostname="localhost",
                 open_timeout=2, close_timeout=2) as ws:
        try:
            ws.recv(timeout=2)
        except TimeoutError:
            print("recv timed out as expected; leaving `with` (close())...")

t = threading.Thread(target=client, daemon=True)
start = time.monotonic()
t.start()
t.join(15)
print(f"{'HUNG' if t.is_alive() else 'returned'} after {time.monotonic() - start:.1f}s")
sys.exit(1 if t.is_alive() else 0)

On ubuntu-latest (Linux 7.0, Python 3.13.18):

websockets 16.0:  recv timed out as expected; leaving `with` (close())...
                  returned after 4.0s
websockets 17.4:  recv timed out as expected; leaving `with` (close())...
                  HUNG after 15.0s

Suggestion: restore self.socket.shutdown(socket.SHUT_RDWR) (ignoring OSError) in close_socket() before self.socket.close(), alongside the notifier.

Workaround for anyone hitting this: dup the socket's fd before connecting and arm a timer that calls shutdown(SHUT_RDWR) on the dup if connect()/close() overruns.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions