Skip to content

On OpenSSL 4, SSL_read_ex() fails with a different error on EOF #30894

Open

Description

@vstinner

Hi,

I'm updating Python to OpenSSL 4 and I get a different error between OpenSSL 3 (3.5.4) and OpenSSL 4 (4.0.0) on SSL_read_ex() on the TLS client after the TLS server closes the socket.

When the server closes the socket (with sslconn.shutdown(socket.SHUT_WR); sslconn.close()), the first SSL_read_ex() on the client fails with SSL_R_UNEXPECTED_EOF_WHILE_READING (same behavior on OpenSSL 3 and OpenSSL 4):

  • SSL_get_error()=SSL_ERROR_SSL
  • ERR_GET_LIB()=ERR_LIB_SSL
  • ERR_GET_REASON()=SSL_R_UNEXPECTED_EOF_WHILE_READING

This error is helpful. My problem is on the second SSL_read_ex() on the client, I get a different error between OpenSSL 3 and OpenSSL 4:

  • OpenSSL 3: SSL_get_error()=SSL_ERROR_SYSCALL, ERR_peek_last_error()=0. Python raises a SSLEOFError in this case (behaves correctly).
  • OpenSSL 4: SSL_get_error()=SSL_ERROR_SSL, ERR_peek_last_error()=0. Python raises a generic SSLError("A failure in the SSL library occurred") in this case (which is unexpected).

OpenSSL 4 sets a generic SSL_ERROR_SSL error with ERR_peek_last_error()=0. It's not helpful to detect an "EOF".

My question is if the different error on OpenSSL 4 was made on purpose, or if it's an issue in OpenSSL 4?


If you want to reproduce my issue, get the Python script:

Details
import os.path
import socket
import ssl
import threading

CERT = os.path.join('Lib', 'test', 'certdata', 'keycert.pem')
HOST = '127.0.0.1'
HOSTNAME = 'localhost'

class Server(threading.Thread):
    def __init__(self):
        super().__init__()
        self.listening = threading.Event()
        self.address = None

    def run(self):
        context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
        context.load_cert_chain(CERT)
        server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        #server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        #server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        server_sock.bind((HOST, 0))
        server_sock.listen(5)

        self.address = server_sock.getsockname()
        self.listening.set()

        sock, addr = server_sock.accept()
        sslconn = context.wrap_socket(sock, server_side=True)

        request = b''
        while True:
            chunk = sslconn.recv(65537)
            request += chunk
            if b'\r\n\r\n' in request:
                break
        print(f"server got request: {request!r}")

        print("server sendall")
        sslconn.sendall(
            b'HTTP/1.0 200 OK\r\n'
            b'Server: TestHTTP/ Python/3.15.0a8+\r\n'
            b'Date: Thu, 16 Apr 2026 12:42:37 GMT\r\n'
            b'Content-type: text/plain\r\n\r\n')
        sslconn.sendall(b'we care a bit')

        print("server shutdown write")
        sslconn.shutdown(socket.SHUT_WR)
        print("server close socket")
        sslconn.close()

        server_sock.close()

def main():
    server = Server()
    server.start()
    server.listening.wait()
    port = server.address[1]

    context = ssl.create_default_context(cafile=CERT)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((HOST, port))
    sslsock = context.wrap_socket(sock, server_hostname=HOSTNAME)

    sslsock.sendall(b'GET /bizarre HTTP/1.0\r\n\r\n')
    sslobj = sslsock._sslobj

    def read(prefix, sslobj):
        try:
            data = sslobj.read(1024)
            result = repr(data)
        except ssl.SSLError as exc:
            result = f'<{exc!r}>'
        print(prefix, result)

    for i in range(1, 5):
        read(f"client read #{i}:", sslobj)
    sslsock.close()

    server.join()

if __name__ == "__main__":
    main()

And run these commands on Linux:

git clone https://github.com/python/cpython --depth=1
cd cpython/
# Build OpenSSL 4 in ~/multissl/openssl/4.0.0/
python3 Tools/ssl/multissltests.py --steps=library --base-directory $HOME/multissl --openssl 4.0.0 --system Linux
./configure --with-pydebug --with-openssl=$HOME/multissl/openssl/4.0.0/
LD_LIBRARY_PATH=~/multissl/openssl/4.0.0/lib64/ make
LD_LIBRARY_PATH=~/multissl/openssl/4.0.0/lib64/ ./python script.py 

Victor

Activity

  1. mattcaswell commented on Apr 20, 2026

    @mattcaswell
    Member
  2. igus68 commented on Apr 20, 2026

    @igus68
    Contributor

    We have discussed it already in #30631:
    when a read or write function is called after the state machine failed into the MSG_FLOW_ERROR state, it is handled as a protocol error.

  3. picnixz commented on Apr 21, 2026

    @picnixz

    Maybe I missed the decision in the other discussion, but should the error queue be empty in this case, even after a protocol failure? how do you suggest we should handle this in Python (Victor suggested to remember whether we hit an EOF to reraise it)?

  4. igus68 commented on Apr 22, 2026

    @igus68
    Contributor

    Could you please clarify your use case? After the first SSL_read_ex() returned a fatal error, what is the reason to call it again? The connection no longer exists, which is why the second read fails. We might push some error like "the connection is in the fatal fail state", but would it be more helpful?
    However, if you really need to call SSL_read_ex() again and want to receive the same error as after the first read, I can suggest not removing the error code from the stack after the first read (use ERR_peek_error() instead of ERR_get_error()).

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

    issue: bug reportThe issue was opened to report a bugtriaged: bugThe issue/pr is/fixes a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions