Skip to content

ssl: PySSL_SetError leaves the ERR_LIB_SYS entry on the OpenSSL error queue, so every later SSL I/O on that thread fails with the same OSError #158313

Description

@liaoruoxing

Bug report

Bug description

Since gh-127257 (GH-127361; backported to 3.12 in GH-127905 and to 3.13 in GH-127812), PySSL_SetError maps an ERR_LIB_SYS entry on the OpenSSL error queue to OSError:

if (ERR_GET_LIB(e) == ERR_LIB_SYS) {
    // A system error is being reported; reason is set to errno
    errno = ERR_GET_REASON(e);
    return PyErr_SetFromErrno(PyExc_OSError);
}

The same block appears in both the SSL_ERROR_SYSCALL and the SSL_ERROR_SSL case. Both return early, before the ERR_clear_error() that every other exit of PySSL_SetError goes through (and that the neighbouring e == 0 branch of SSL_ERROR_SYSCALL calls explicitly). The entry therefore stays on the thread's error queue.

OpenSSL's SSL_get_error() inspects that queue before anything else — the man page says "The current thread's error queue must be empty before the TLS/SSL I/O operation is attempted, or SSL_get_error() will not work reliably" — so the next SSL_read_ex()/SSL_write_ex() on that thread that does not complete immediately (would-block, EOF, …), on any connection, is reported as SSL_ERROR_SYSCALL/SSL_ERROR_SSL; PySSL_SetError takes the same early return, raises the same stale OSError, and again does not clear. The thread stays poisoned for the life of the process.

Nothing in the ssl module recovers from this. What sometimes does is a coincidence: OpenSSL's state_machine() calls ERR_clear_error() on entry, so a post-handshake message (e.g. a TLS 1.3 NewSessionTicket) processed inside a later read cleans up by accident. That is why this is hard to see in small tests and easy to hit in a long-running client that handshakes connections on one thread and reads them on another.

Reproducer

Loopback only; needs the openssl CLI for a throwaway certificate.

"""Two TLS connections on loopback. The peer resets the first; sending on it raises ConnectionResetError,
which is correct. The second connection is healthy and idle, so recv() on it should wait for the timeout.
Instead it raises ConnectionResetError at once, and keeps doing so on every call.

Session tickets are switched off on purpose: TLS 1.3 sends them after the handshake and the client processes
them inside its next read, which runs OpenSSL's state machine, whose entry point calls ERR_clear_error() and
would hide the bug by coincidence."""
import os, socket, ssl, struct, subprocess, sys, tempfile, threading, time

print(sys.version.split()[0], ssl.OPENSSL_VERSION, sys.platform)
d = tempfile.mkdtemp()
crt, key = os.path.join(d, "cert.pem"), os.path.join(d, "key.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
                "-keyout", key, "-out", crt, "-days", "1", "-subj", "/CN=localhost"], check=True, capture_output=True)
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(crt, key)
server_ctx.num_tickets = 0
client_ctx = ssl.create_default_context()
client_ctx.check_hostname, client_ctx.verify_mode = False, ssl.CERT_NONE

listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(5)
held = []

def serve():
    while True:
        conn, _ = listener.accept()
        tls = server_ctx.wrap_socket(conn, server_side=True)
        tls.settimeout(10)
        if tls.recv(16) == b"reset":
            raw = socket.socket(fileno=tls.detach())
            raw.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
            raw.close()                      # RST, no close_notify
        else:
            held.append(tls)                 # stays open and silent

threading.Thread(target=serve, daemon=True).start()

def connect(first_message):
    c = client_ctx.wrap_socket(socket.create_connection(listener.getsockname()), server_hostname="localhost")
    c.settimeout(1)
    c.send(first_message)
    return c

def show(label, op):
    t = time.time()
    try:
        r = op()
        print(f"{label}: returned {r!r} after {time.time() - t:.2f}s")
    except Exception as e:
        print(f"{label}: {type(e).__name__}({getattr(e, 'errno', None)}) after {time.time() - t:.2f}s")

healthy = connect(b"idle")
show("healthy.recv() before      ", lambda: healthy.recv(100))     # TimeoutError after 1s: correct
broken = connect(b"reset")
time.sleep(0.2)
show("broken.send() after peer RST", lambda: broken.send(b"x"))      # ConnectionResetError: correct
for i in range(3):
    show(f"healthy.recv() after, try {i + 1}", lambda: healthy.recv(100))  # expected: TimeoutError after 1s

Python 3.12.13, OpenSSL 3.5.5, Linux aarch64 (the python:3.12-slim image):

healthy.recv() before      : TimeoutError(None) after 1.00s
broken.send() after peer RST: ConnectionResetError(104) after 0.00s
healthy.recv() after, try 1: ConnectionResetError(104) after 0.00s
healthy.recv() after, try 2: ConnectionResetError(104) after 0.00s
healthy.recv() after, try 3: ConnectionResetError(104) after 0.00s

The ConnectionResetError from broken.send() is correct. The three from healthy.recv() are not: that connection is open and idle, and recv() should have waited 1 s and raised TimeoutError, as it did before.

Same script on Python 3.12.8, OpenSSL 3.5.7, macOS (predates the backport):

healthy.recv() before      : TimeoutError(None) after 1.00s
broken.send() after peer RST: SSLError(5) after 0.00s
healthy.recv() after, try 1: TimeoutError(None) after 1.00s
healthy.recv() after, try 2: TimeoutError(None) after 1.00s
healthy.recv() after, try 3: TimeoutError(None) after 1.00s

(The SSLError for the reset connection is the older, less precise report that GH-127361 improved on; the healthy connection is unaffected because that code path ends in fill_and_set_sslerror() + ERR_clear_error().)

How it was found

slack_sdk's builtin Socket Mode client reads every WebSocket connection it will ever have on one long-lived thread and handshakes new ones on another. One TLS connection reset by a network gateway put the receive thread's queue in this state. From then on every new connection was established fine, delivered one record, and failed with BrokenPipeError on the first would-block read — reconnect, repeat every 10 seconds, for three days, with the process otherwise healthy. strace showed no failing syscall at all in those cycles (the read returned EAGAIN), and reading the thread's queue through ctypes showed two entries: SSL_R_UNEXPECTED_EOF_WHILE_READING from the reset and ERR_LIB_SYS/EPIPE from OpenSSL's attempt to send the alert (tls_retry_write_records), so SSL_get_error() answered SSL_ERROR_SSL and the early return in that case was the one taken. Calling ERR_clear_error() from the receive thread recovered the client immediately.

Suggested fix

Call ERR_clear_error() before the two return PyErr_SetFromErrno(PyExc_OSError); in the ERR_LIB_SYS branches, as the e == 0 branch of SSL_ERROR_SYSCALL already does. (The broader request to clear the queue before every I/O call is gh-81891 / bpo-37710; this report is about the specific regression from GH-127361.)

CPython versions tested on

3.12.13 (affected); 3.12.8 (not affected). The same two early returns are on main today.

Operating systems tested on

Linux (aarch64, Debian-based python:3.12-slim); macOS for the unaffected comparison.

Linked PRs

Activity

  1. jonathan-simms commented on Oct 23, 2026

    @jonathan-simms

    Confirming this from production, with a different library and a clean repro of the mechanism.

    Environment: python:3.12-slim → Python 3.12.15, OpenSSL 3.5.7, slack_sdk 3.33.4 using its dependency-free builtin websocket client (plain ssl.SSLSocket, one long-lived receive thread reused across reconnects).

    Symptom: after one TLS write failure on 2026-10-18 the receive thread never recovered: every reconnect completed the handshake, read the server hello, and then the first non-blocking recv that should have been EAGAIN raised BrokenPipeError(32). The library's auto-reconnect looped like that for five days.

    Evidence that it's the stale error queue, not the socket:

    • strace -f on the thread for one cycle: read returns -1 EAGAIN, no EPIPE anywhere in the trace, no write failing, and Python raises BrokenPipeError immediately after that EAGAIN. The errno in the exception comes from ERR_GET_REASON of the queued ERR_LIB_SYS entry (tls_retry_write_records failure, errno 32 from the original failed write), not from the syscall.
    • _ssl.c PySSL_SetError returns via PyErr_SetFromErrno on the ERR_LIB_SYS branch before reaching ERR_clear_error(), and _ssl__SSLSocket_read_impl does not clear the queue before SSL_read_ex, so SSL_get_error keeps reporting SSL_ERROR_SYSCALL from ERR_peek_error() on every later read from that thread.
    • A fresh SocketModeClient (new threads) started inside the same process/container connected and stayed up while the original thread was still looping. Same token, same host, same SSL context. Only the thread differed.

    Workaround we shipped: a watchdog that discards the client and its threads when the connection stays down, since there is no way to clear the per-thread queue from Python. Happy to provide the strace excerpt if useful.

  2. ifor commented on Nov 3, 2026

    @ifor

    Another production confirmation, and a trigger that needs no failed write by the application.

    Environment: python:3.12-slim images: 3.12.13 (OpenSSL 3.5.6) and 3.12.14, 3.12.15, 3.12.16 (OpenSSL 3.5.7), Linux x86_64. All are affected. Ubuntu 24.04's 3.12.3 with OpenSSL 3.0.13 is not.

    1. A server that cuts a response off is enough. Suppose the peer sends FIN and then RST in the middle of a response, with no close_notify. (We see this from a council portal that ends long downloads after about 25 s.) OpenSSL records SSL_R_UNEXPECTED_EOF_WHILE_READING and tries to send a fatal alert. That write fails with EPIPE and adds an ERR_LIB_SYS entry. PySSL_SetError reads ERR_peek_last_error(), which is the ERR_LIB_SYS entry, and takes the early return in the SSL_ERROR_SSL case. So recv() raises BrokenPipeError instead of SSLEOFError, and both entries stay queued. From then on, every recv() in that thread that has to wait, on any connection, raises BrokenPipeError at once. Loopback only, anonymous ECDH, so no certificate is needed:

    """No failed write by the application: a server that cuts a response off (FIN, then RST,
    no close_notify) makes recv() raise BrokenPipeError and poisons the thread."""
    import socket, ssl, struct, sys, threading, time
    
    def ctx(server):
        c = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER if server else ssl.PROTOCOL_TLS_CLIENT)
        c.maximum_version = ssl.TLSVersion.TLSv1_2
        c.set_ciphers("aNULL:@SECLEVEL=0")
        if not server:
            c.check_hostname, c.verify_mode = False, ssl.CERT_NONE
        return c
    
    listener = socket.socket()
    listener.bind(("127.0.0.1", 0))
    listener.listen(5)
    held = []
    
    def serve():
        sctx = ctx(True)
        while True:
            raw, _ = listener.accept()
            tls = sctx.wrap_socket(raw, server_side=True)
            if tls.recv(16) == b"cut":
                tls.sendall(b"part of a body")
                s = socket.socket(fileno=tls.detach())   # no close_notify
                s.shutdown(socket.SHUT_WR)               # FIN ...
                s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
                s.close()                                # ... then RST
            else:
                held.append(tls)                         # stays open and silent
    
    threading.Thread(target=serve, daemon=True).start()
    cctx = ctx(False)
    
    def connect(first):
        c = cctx.wrap_socket(socket.create_connection(listener.getsockname()))
        c.settimeout(1)
        c.send(first)
        return c
    
    def show(label, op):
        t = time.time()
        try:
            r = op()
            print(f"{label}: returned {r!r} after {time.time() - t:.2f}s")
        except Exception as e:
            print(f"{label}: {type(e).__name__}({getattr(e, 'errno', None)}) after {time.time() - t:.2f}s")
    
    print(sys.version.split()[0], ssl.OPENSSL_VERSION, sys.platform)
    healthy = connect(b"idle")
    show("healthy.recv() before         ", lambda: healthy.recv(100))     # TimeoutError: correct
    cut = connect(b"cut")
    time.sleep(0.3)                                                      # FIN and RST are in
    show("cut.recv() (the part)         ", lambda: cut.recv(100))
    show("cut.recv() (FIN+RST, no alert)", lambda: cut.recv(100))         # expected: an EOF
    for i in range(3):
        show(f"healthy.recv() after, try {i + 1} ", lambda: healthy.recv(100))  # expected: TimeoutError

    Python 3.12.16, OpenSSL 3.5.7:

    healthy.recv() before         : TimeoutError(None) after 1.00s
    cut.recv() (the part)         : returned b'part of a body' after 0.00s
    cut.recv() (FIN+RST, no alert): BrokenPipeError(32) after 0.00s
    healthy.recv() after, try 1 : BrokenPipeError(32) after 0.00s
    healthy.recv() after, try 2 : BrokenPipeError(32) after 0.00s
    healthy.recv() after, try 3 : BrokenPipeError(32) after 0.00s
    

    Python 3.12.3, OpenSSL 3.0.13:

    cut.recv() (FIN+RST, no alert): returned b'' after 0.00s
    healthy.recv() after, try 1 : TimeoutError(None) after 1.00s
    

    2. Connection pools make it last. We used one boto3 client shared by a thread pool. A single reset during an S3 upload left two threads failing every request about 0.25 ms after sending it, for eight hours, with ConnectionClosedError: Connection was closed before we received a valid response. Other threads were using the same pooled connections without trouble. Each failure throws away a healthy pooled connection, but the other threads keep the pool full. So the poisoned thread rarely opens a connection of its own, and that is the only thing that clears it (newPySSLSocket's ERR_clear_error(), or the handshake state machine).

    A workaround that holds: call ERR_clear_error() before each request, through ctypes on _ssl's own libcrypto handle (a botocore before-send handler, or requests.Session.send). The fix suggested in the report would cover both triggers: ERR_clear_error() before the two ERR_LIB_SYS returns, at Modules/_ssl.c lines 655–659 and 685–689 on v3.12.16 (755–759 and 791–795 on main). For trigger 1 there is a further question. When the queue holds SSL_R_UNEXPECTED_EOF_WHILE_READING followed by the ERR_LIB_SYS from the failed alert, the EOF is arguably the error to report.

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

    topic-SSLtype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions