Repository navigation
Description
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.
Activity