Skip to content

gh-148594: Clear stale OpenSSL errors before TLS. I/O. - #159641

Open
mahhie418 wants to merge 2 commits into
python:mainfrom
mahhie418:fix/gh-148594
Open

mahhie418 wants to merge 2 commits into
python:mainfrom
mahhie418:fix/gh-148594

Conversation

@mahhie418

@mahhie418 mahhie418 commented Oct 17, 2026 •

Copy link
Copy Markdown

This PR. fixes #148594 and fixes #81891 (duplicate reports of the same bug), following the discussion in #148597.

Stale entries in the thread-local OpenSSL error queue can cause SSL_get_error() to misclassify retryable conditions, leading to spurious exceptions.

Clear the queue immediately before each SSL_read_ex(), SSL_write_ex(), SSL_do_handshake(), SSL_shutdown() and SSL_sendfile() invocation, including retries. Error classification remains immediately after each operation.

Clearing errors only where CPython raises them would not cover entries left by other OpenSSL users on the same thread. Clearing at the I/O. call sites ensures that the precondition is met regardless of where the stale errors originated.

Add regression tests for read(), write() and unwrap(), based on the reproducer provided by @devdanzin in #148594. They reuse the existing test infrastructure and certificate fixtures without requiring ctypes, gevent, external network access or certificate generation. All three fail before the fix and pass afterwards.

The handshake and sendfile changes enforce the same documented precondition. A stale-queue failure has not been reproduced in the handshake path, and Kernel TLS sendfile was not tested on macOS. SSL_pending() is unchanged because it reports buffered byte counts rather than the result of a TLS. I/O. operation.

Validation on macOS arm64 with a CPython 3.16 debug build and OpenSSL 3.5.9:

  • ./python.exe -m test test_ssl
  • ./python.exe -m test -R 3:3 test_ssl
  • ./python.exe -m test -j0
  • make patchcheck

All checks passed. No leaks were reported after warm-up. The default suite completed with platform and resource skips.

@python-cla-bot

python-cla-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

@mahhie418

Copy link
Copy Markdown
Author

I have merged the latest main and resolved the conflict in Lib/test/test_ssl.py, retaining both the stale-error-queue regression tests and the upstream certificate verification test.

@mahhie418

Copy link
Copy Markdown
Author

@picnixz, the workflows triggered after I resolved the merge conflict on 24 October 2026 are still awaiting approval. Could you approve them when convenient? Thank you.

@mahhie418

Copy link
Copy Markdown
Author

I have checked the ASan failure. test_sampling_with_collapsed_export failed with AssertionError: ':' not found in '<unreadable frame>', which appears to match #160334. All six SSL.-specific test jobs passed.

@picnixz, could you rerun the failed job when convenient? Thank you.

@picnixz

picnixz commented Oct 31, 2026

Copy link
Copy Markdown
Member

We'll rerun workflows when necessary, before the review. If they are unrelated, it's ok. No need to explicitly have a gvreen CI 100% of the time.

@mahhie418

Copy link
Copy Markdown
Author

Understood, thank you for clarifying.

@KVladislav

Copy link
Copy Markdown

Independent confirmation from a different setup, in case it helps review: Linux, an ordinary OS-thread server, real sockets, stdlib only. Same symptom as @mahhie418's macOS report. I have not built or run this PR; the --clear row below is the user-space equivalent of the patch.

Setup: one thread accepts and wraps sockets, a second worker thread reads them (the usual thread-pool server layout). One client completes a TLS 1.3 handshake and closes without sending anything. The worker's read of that socket raises BrokenPipeError, which is legitimate, but leaves 0xa000126 (unexpected eof while reading) and 0x80000020 (system error, errno 32) on the worker thread's queue. After that, every fresh connection whose first read has to wait raises BrokenPipeError in the worker instead of waiting, and the server drops it. newPySSLSocket() clears the queue, but on the accepting thread, not the reading one.

Five trials per row, 20 connections each, client sends 0.2 s after the handshake:

3.14.4 (Ubuntu 26.04), OpenSSL 3.5.5 3.15.0 (python-build-standalone), OpenSSL 3.5.9
no handshake-and-close client 20/20 ×5 20/20 ×5
--probe 0/20 ×5 0/20 ×5
--probe --clear (ERR_clear_error() via ctypes before each read) 20/20 ×5 n/a: ERR_* not exported by that build
--probe --same-thread (accept and read on one thread) 20/20 ×5 20/20 ×5
--probe --no-tickets (num_tickets = 0) 20/20 ×5 20/20 ×5

The trigger is a race. Over 40 runs the probe poisoned the worker 38 times on 3.14.4 and 37 on 3.15.0; in the remaining runs its read raised ConnectionResetError instead, and later connections were served.

3.14.4  OpenSSL 3.5.5 27 Jan 2026  --probe  answered 0 of 20
  server: read BrokenPipeError; queue before: 0x0..0x0, after: 0xa000126..0x80000020 1
  server: read BrokenPipeError; queue before: 0xa000126..0x80000020, after: 0xa000126..0x80000020 20

A related leak this PR does not touch, and I don't think it should block it. The first line above shows the queue empty before the read and dirty after it: _ssl leaves the entries itself. PySSL_SetError() ends with ERR_clear_error() (L804), but the ERR_LIB_SYS branches added in gh-127257 return through PyErr_SetFromErrno() before reaching it (L758, L794). The control is the same disconnect with --no-tickets: the read ends in a ragged EOF instead of EPIPE, and the queue after it is 0x0..0x0. With this PR applied, _ssl's own I/O should no longer trip over the leftover, but it is still left for any other OpenSSL user on the same thread, which is the original #81891 scenario in reverse. I can open a separate issue for it if that is preferred.

repro.py
"""Stale OpenSSL error queue in a server thread drops the next TLS connection.

A server accepts and wraps sockets in one thread and reads them in a single
worker thread (the usual accept-then-hand-off thread-pool layout).
One client completes the TLS handshake and closes without sending anything.
The worker's read of that socket fails, and leaves an error on the worker
thread's OpenSSL error queue. After that, every fresh connection whose first
read has to wait fails in the worker instead of waiting.

    python3 repro.py [--probe] [--clear] [--same-thread] [--no-tickets] [--tls12]
                     [-n N] [--delay S]

  --probe        first run one client that handshakes and closes, sending nothing
  --clear        call ERR_clear_error() (via ctypes) in the worker before each read
  --same-thread  accept, wrap and read in one thread (no hand-off)
  --no-tickets   server sends no TLS 1.3 session tickets (num_tickets = 0)
  --tls12        cap the server at TLS 1.2

The server prints, per outcome, the worker thread's error queue (first..last
entry, via ctypes) before and after the read; 0x0..0x0 is empty.

Standard library only; needs the `openssl` CLI to make a throwaway self-signed pair.
"""
import argparse
import collections
import ctypes
import os
import queue
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time

p = argparse.ArgumentParser()
p.add_argument("--probe", action="store_true")
p.add_argument("--clear", action="store_true")
p.add_argument("--same-thread", action="store_true")
p.add_argument("--no-tickets", action="store_true")
p.add_argument("--tls12", action="store_true")
p.add_argument("-n", type=int, default=20)
p.add_argument("--delay", type=float, default=0.2)
args = p.parse_args()

# ERR_* from the libcrypto the ssl module actually uses: looked up through the
# _ssl extension, whose dependencies dlsym() searches, or through the
# interpreter itself when _ssl is built in. None if not exported.
try:
    import _ssl
    crypto = ctypes.CDLL(getattr(_ssl, "__file__", None))
    crypto.ERR_peek_error.restype = ctypes.c_ulong
    crypto.ERR_peek_last_error.restype = ctypes.c_ulong
    crypto.ERR_clear_error
except (AttributeError, OSError):
    crypto = None
if args.clear and crypto is None:
    sys.exit("--clear: ERR_clear_error is not reachable through _ssl on this build")

tmp = tempfile.mkdtemp()
cert, key = os.path.join(tmp, "c.pem"), os.path.join(tmp, "k.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
                "-keyout", key, "-out", cert, "-days", "1", "-subj", "/CN=localhost"],
               check=True, capture_output=True)

sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sctx.load_cert_chain(cert, key)
shutil.rmtree(tmp)  # loaded; the throwaway key is not needed on disk any more
if args.no_tickets:
    sctx.num_tickets = 0
if args.tls12:
    sctx.maximum_version = ssl.TLSVersion.TLSv1_2
cctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
cctx.check_hostname = False
cctx.verify_mode = ssl.CERT_NONE

lsock = socket.create_server(("127.0.0.1", 0))
port = lsock.getsockname()[1]
handoff = queue.Queue()
log = collections.Counter()


def accept_and_wrap():
    conn, _ = lsock.accept()
    conn.settimeout(5)  # a read that has to wait goes through SSL_get_error
    try:
        return sctx.wrap_socket(conn, server_side=True)  # handshake here
    except (OSError, ssl.SSLError) as e:
        log["handshake " + type(e).__name__] += 1
        conn.close()


def queue_state():
    if crypto is None:
        return "n/a"
    return "%#x..%#x" % (crypto.ERR_peek_error(), crypto.ERR_peek_last_error())


def serve_one(s):
    if args.clear:
        crypto.ERR_clear_error()
    before = queue_state()
    try:
        data = s.recv(1024)
        if data:
            s.sendall(b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok")
            log["read ok"] += 1
        else:
            log["read eof (ragged EOF suppressed); queue after: %s" % queue_state()] += 1
    except (OSError, ssl.SSLError) as e:
        log["read %s; queue before: %s, after: %s"
            % (type(e).__name__, before, queue_state())] += 1
    finally:
        s.close()


def acceptor():
    while True:
        s = accept_and_wrap()
        if s:
            handoff.put(s)


def worker():
    while True:
        serve_one(handoff.get())


def single_thread():
    while True:
        s = accept_and_wrap()
        if s:
            serve_one(s)


if args.same_thread:
    threading.Thread(target=single_thread, daemon=True).start()
else:
    threading.Thread(target=acceptor, daemon=True).start()
    threading.Thread(target=worker, daemon=True).start()


def connect():
    return cctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=5))


if args.probe:
    connect().close()  # handshake, then close without sending anything
    time.sleep(0.2)

answered = 0
for i in range(args.n):
    c = connect()
    time.sleep(args.delay)  # the request arrives after the server's first read
    try:
        c.sendall(b"GET / HTTP/1.0\r\n\r\n")
        answered += c.recv(100).startswith(b"HTTP/1.0 200")
    except OSError:
        pass
    c.close()

time.sleep(0.2)
print("%s  %s  %s  answered %d of %d"
      % (sys.version.split()[0], ssl.OPENSSL_VERSION, " ".join(sys.argv[1:]) or "(no flags)",
         answered, args.n))
for k, v in sorted(log.items()):
    print("  server: %-72s %d" % (k, v))

@mahhie418

Copy link
Copy Markdown
Author

Thanks for the detailed investigation, @KVladislav.

The Linux results are consistent with what I observed on macOS. This is helpful additional evidence for clearing the queue before SSL. I/O. operations.

I agree that the errors left by the early returns in PySSL_SetError() would be best tracked in a separate issue. This PR. leaves those error-handling paths unchanged, so a separate issue would be helpful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants