Files
RustPython/extra_tests/snippets/stdlib_ssl.py
T
youknowdot 51e886c224 Fix optional OpenSSL duplex I/O on Unix (#9173)
* Fix optional OpenSSL duplex I/O on Unix

Release connection locks before readiness waits while serializing native SSL
attempts and preserving owned write retries. Wake waiters on native progress
and socket identity invalidation, and construct callback/native exceptions
after unlocking.

Add regression coverage to the existing SSL snippet and run the optional
OpenSSL backend in the existing Unix CI matrix. Windows and AIX retain the
legacy transport pending a separate compatibility-safe driver.

Assisted-by: OpenAI Codex:model version unavailable

* Consolidate OpenSSL runtime checks into the existing CI step

Keep the minimal system and vendored OpenSSL build checks, and run the
Unix SSL regressions in the existing system OpenSSL step. Preserve the
runtime feature set and enable its macOS dependency in that job.

Assisted-by: OpenAI Codex:model version unavailable
2026-10-17 23:36:27 +09:00

352 lines
14 KiB
Python

import pathlib
import socket
import ssl
import threading
import time
from test.test_ssl import HOST, ThreadedEchoServer, testing_context
CERT = (
pathlib.Path(__file__).resolve().parent.parent.parent
/ "Lib"
/ "test"
/ "certdata"
/ "keycert.pem"
)
TAIL = b"tail"
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(str(CERT))
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.check_hostname = False
client_context.verify_mode = ssl.CERT_NONE
server_context.maximum_version = client_context.maximum_version = ssl.TLSVersion.TLSv1_2
client_in, client_out = ssl.MemoryBIO(), ssl.MemoryBIO()
server_in, server_out = ssl.MemoryBIO(), ssl.MemoryBIO()
client = client_context.wrap_bio(client_in, client_out)
server = server_context.wrap_bio(server_in, server_out, server_side=True)
# A write that starts the handshake must release its caller's buffer on WANT.
# Save the valid retry before resizing and changing the original exporter.
exporter = bytearray(b"saved application bytes before WANT_READ")
saved_write = bytes(exporter)
try:
client.write(exporter)
except ssl.SSLWantReadError:
exporter[:] = b"changed after WANT"
exporter.extend(b"; no outstanding buffer export")
else:
raise AssertionError("write before the peer handshake did not need a read")
for _ in range(5):
try:
client.do_handshake()
except ssl.SSLWantReadError:
pass
server_in.write(client_out.read())
try:
server.do_handshake()
except ssl.SSLWantReadError:
pass
client_in.write(server_out.read())
client.do_handshake()
server.do_handshake()
# OpenSSL retries require the original content and length. Never retry with
# the mutated exporter: behavior for an invalid retry is not a parity claim.
assert client.write(saved_write) == len(saved_write)
server_in.write(client_out.read())
assert server.read(len(saved_write)) == saved_write
try:
server.unwrap()
except ssl.SSLWantReadError:
pass
client_in.write(server_out.read() + TAIL)
client.unwrap()
assert client_in.read() == TAIL
TIMEOUT = 10.0
class BoundedEchoServer(ThreadedEchoServer):
# Keep the canonical accept, handshake and echo implementation. Its normal
# context manager and handler joins need bounds for a deadlock regression.
class ConnectionHandler(ThreadedEchoServer.ConnectionHandler):
def __init__(self, server, connsock, addr):
super().__init__(server, connsock, addr)
self.sock.settimeout(TIMEOUT)
server.handlers.append(self)
def run(self):
try:
try:
super().run()
finally:
self.close()
except BaseException as exc:
self.server.errors.append(exc)
def join(self, timeout=TIMEOUT * 10):
super().join(timeout)
if self.is_alive():
self.server.errors.append(
AssertionError("echo connection handler did not stop")
)
self.server.stop()
def __init__(self, context):
self.errors = []
self.handlers = []
super().__init__(context=context, chatty=False)
def __enter__(self):
assert not self._in_context
self._in_context = True
self.start(threading.Event())
if not self.flag.wait(TIMEOUT):
self.__exit__()
raise AssertionError("echo server did not start")
return self
def run(self):
try:
try:
super().run()
finally:
self.close()
except BaseException as exc:
self.errors.append(exc)
def __exit__(self, *args):
self.stop()
self.join(TIMEOUT * 2)
self._in_context = False
assert not self.is_alive(), "echo server did not stop"
for handler in self.handlers:
handler.join(TIMEOUT)
assert not handler.is_alive(), "echo connection handler did not stop"
if self.errors:
raise self.errors[0]
assert not self.conn_errors, self.conn_errors
def test_write_return_during_reader_callback():
client_context, server_context, hostname = testing_context()
client_context.maximum_version = ssl.TLSVersion.TLSv1_2
server_context.maximum_version = ssl.TLSVersion.TLSv1_2
writer_thread = threading.current_thread()
reader_thread = None
round_active = False
def message_callback(conn, direction, version, content_type, msg_type, data):
if (
not round_active
or conn is not connection
or content_type != ssl._TLSContentType.HEADER
or msg_type != ssl._TLSContentType.APPLICATION_DATA
):
return
current_thread = threading.current_thread()
if (
direction == "write"
and current_thread is writer_thread
and not write_started.is_set()
):
write_started.set()
assert reader_started.wait(TIMEOUT), "reader did not start"
# Encourage the reader to queue behind the native writer. Python
# cannot force its acquisition into the native cleanup window.
time.sleep(0.01)
elif (
direction == "read"
and current_thread is reader_thread
and not read_callback_seen.is_set()
):
read_callback_seen.set()
assert write_returned.wait(TIMEOUT), (
"write did not return while another thread's read callback ran"
)
client_context._msg_callback = message_callback
with BoundedEchoServer(server_context) as echo_server:
with client_context.wrap_socket(
socket.socket(), server_hostname=hostname
) as connection:
connection.settimeout(TIMEOUT)
connection.connect((HOST, echo_server.port))
try:
# This is a cleanup/callback race, not another generic duplex
# smoke test. The canonical duplex test remains authoritative.
for _ in range(20):
payload = b"WRITE RETURN DURING READER CALLBACK\n"
write_started = threading.Event()
reader_started = threading.Event()
write_returned = threading.Event()
read_callback_seen = threading.Event()
errors = []
def read_echo():
try:
assert write_started.wait(TIMEOUT), (
"writer did not enter its application-record callback"
)
reader_started.set()
received = b""
while len(received) < len(payload):
chunk = connection.read(len(payload) - len(received))
assert chunk, (
"unexpected EOF in echoed application data"
)
received += chunk
assert received == payload.lower(), received
except BaseException as exc:
errors.append(exc)
reader_thread = threading.Thread(target=read_echo, daemon=True)
round_active = True
reader_thread.start()
try:
assert connection.write(payload) == len(payload)
finally:
# Set only after the Python write call has returned or
# raised. The reader callback never waits on itself.
write_returned.set()
reader_thread.join(TIMEOUT * 2)
if reader_thread.is_alive():
# Interrupt transport I/O without starting SSL I/O.
try:
socket.socket.shutdown(connection, socket.SHUT_RDWR)
except OSError:
pass
reader_thread.join(TIMEOUT * 2)
round_active = False
assert not reader_thread.is_alive(), "reader did not stop"
if errors:
raise errors[0]
assert write_started.is_set(), "write callback was not exercised"
assert read_callback_seen.is_set(), (
"read callback was not exercised"
)
connection.write(b"over\n")
finally:
round_active = False
client_context._msg_callback = None
test_write_return_during_reader_callback()
# Limit initial nonblocking writes to 4 MiB. Small socket buffers plus a peer
# paused after its handshake must actually produce WANT_WRITE on this platform.
WRITE_SIZE = 64 * 1024
MAX_INITIAL_WRITES = 64
class PausedSinkServer(BoundedEchoServer):
class ConnectionHandler(BoundedEchoServer.ConnectionHandler):
def __init__(self, server, connsock, addr):
super().__init__(server, connsock, addr)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096)
def read(self):
assert self.server.read_enabled.wait(TIMEOUT), "sink was not released"
data = super().read()
if not data:
self.server.reached_eof.set()
else:
assert data == b"x" * len(data), "pending write data changed"
self.server.received += len(data)
assert self.server.received <= (MAX_INITIAL_WRITES + 1) * WRITE_SIZE
return data
def write(self, data):
# Data was checked before canonical echo processing normalizes it.
# Send nothing back, avoiding two-peer application backpressure.
pass
def run(self):
try:
super().run()
finally:
self.server.finished.set()
def __init__(self, context):
self.read_enabled = threading.Event()
self.finished = threading.Event()
self.reached_eof = threading.Event()
self.received = 0
super().__init__(context)
def test_exporter_release_after_application_want_write():
client_context, server_context, hostname = testing_context()
client_context.maximum_version = ssl.TLSVersion.TLSv1_2
server_context.maximum_version = ssl.TLSVersion.TLSv1_2
with PausedSinkServer(server_context) as sink:
try:
with client_context.wrap_socket(
socket.socket(), server_hostname=hostname
) as connection:
connection.settimeout(TIMEOUT)
connection.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096)
connection.connect((HOST, sink.port))
connection.setblocking(False)
exporter = bytearray(b"x" * WRITE_SIZE)
saved_write = bytes(exporter)
acknowledged = 0
for _ in range(MAX_INITIAL_WRITES):
try:
acknowledged += connection.write(exporter)
except ssl.SSLWantWriteError:
break
else:
raise AssertionError(
"bounded application writes never reached WANT_WRITE"
)
# Returning WANT must release the export while native retries
# retain stable original bytes. Resize and mutate only the old
# exporter; the retry still uses identical saved bytes/length.
exporter[:] = b"changed after application WANT_WRITE"
exporter.extend(b"; exporter is no longer held")
sink.read_enabled.set()
# A successful write may accept a prefix. The first retry
# must still use identical bytes/length; advance only after a
# positive result, and keep the same pending slice on WANT.
retry_deadline = time.monotonic() + TIMEOUT
accepted = 0
pending = saved_write
for _ in range(64):
remaining = retry_deadline - time.monotonic()
assert remaining > 0, "saved-byte retry deadline expired"
connection.settimeout(remaining)
try:
count = connection.write(pending)
except (ssl.SSLWantReadError, ssl.SSLWantWriteError):
continue
assert 0 < count <= len(pending), count
accepted += count
if accepted == len(saved_write):
break
pending = saved_write[accepted:]
else:
raise AssertionError("saved-byte retry exceeded 64 attempts")
assert accepted == len(saved_write)
# TCP EOF follows every sent TLS byte. Let the sink consume to
# EOF before checking the total, so extra replay is detected.
socket.socket.shutdown(connection, socket.SHUT_WR)
assert sink.finished.wait(TIMEOUT), "sink did not consume through EOF"
assert sink.reached_eof.is_set(), "sink stopped before observing EOF"
assert sink.received == acknowledged + len(saved_write), sink.received
finally:
# Setup or oracle failure must not strand the paused peer.
sink.read_enabled.set()
test_exporter_release_after_application_want_write()