Skip to content

Commit 1d749eb

Browse files
committed
Fix SSL read over-consuming TCP data and ZeroReturn handling
Use single-record reading (recv_one_tls_record) for all SSL reads, not just handshake. This prevents rustls from eagerly consuming close_notify alongside application data, which left the TCP buffer empty and caused select()-based servers to miss readability and time out. Also fix ZeroReturn to raise SSLEOFError when shutdown has not started, so suppress_ragged_eofs=True can suppress it properly.
1 parent 7c0981b commit 1d749eb

File tree

3 files changed

+42
-54
lines changed

3 files changed

+42
-54
lines changed

Lib/test/test_ftplib.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,6 @@ def retr():
903903
retr()
904904

905905

906-
@unittest.skip("TODO: RUSTPYTHON; SSL + asyncore has problem")
907906
@skipUnless(ssl, "SSL not available")
908907
@requires_subprocess()
909908
class TestTLS_FTPClassMixin(TestFTPClass):
@@ -922,7 +921,6 @@ def setUp(self, encoding=DEFAULT_ENCODING):
922921

923922

924923
@skipUnless(ssl, "SSL not available")
925-
@unittest.skip("TODO: RUSTPYTHON; SSL + asyncore has problem")
926924
@requires_subprocess()
927925
class TestTLS_FTPClass(TestCase):
928926
"""Specific TLS_FTP class tests."""
@@ -971,7 +969,6 @@ def test_data_connection(self):
971969
LIST_DATA.encode(self.client.encoding))
972970
self.assertEqual(self.client.voidresp(), "226 transfer complete")
973971

974-
@unittest.skip('TODO: RUSTPYTHON flaky TimeoutError')
975972
def test_login(self):
976973
# login() is supposed to implicitly secure the control connection
977974
self.assertNotIsInstance(self.client.sock, ssl.SSLSocket)
@@ -984,7 +981,6 @@ def test_auth_issued_twice(self):
984981
self.client.auth()
985982
self.assertRaises(ValueError, self.client.auth)
986983

987-
@unittest.skip('TODO: RUSTPYTHON flaky TimeoutError')
988984
def test_context(self):
989985
self.client.quit()
990986
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)

crates/stdlib/src/ssl.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,19 +3629,22 @@ mod _ssl {
36293629
return return_data(buf, &buffer, vm);
36303630
}
36313631
}
3632-
// Clean closure with close_notify
3633-
// CPython behavior depends on whether we've sent our close_notify:
3634-
// - If we've already sent close_notify (unwrap was called): raise SSLZeroReturnError
3635-
// - If we haven't sent close_notify yet: return empty bytes
3632+
// Clean closure via close_notify from peer.
3633+
// The handling depends on our shutdown state:
3634+
// - If we already sent close_notify (unwrap was called):
3635+
// Raise SSLZeroReturnError (bidirectional shutdown).
3636+
// - If we haven't sent close_notify yet:
3637+
// Raise SSLEOFError so SSLSocket.read() can suppress it
3638+
// via suppress_ragged_eofs=True (returning b'').
3639+
// This also lets asyncore's SSLConnection.recv() catch
3640+
// SSL_ERROR_EOF and call handle_close().
36363641
let our_shutdown_state = *self.shutdown_state.lock();
36373642
if our_shutdown_state == ShutdownState::SentCloseNotify
36383643
|| our_shutdown_state == ShutdownState::Completed
36393644
{
3640-
// We already sent close_notify, now receiving peer's → SSLZeroReturnError
36413645
Err(create_ssl_zero_return_error(vm).upcast())
36423646
} else {
3643-
// We haven't sent close_notify yet → return empty bytes
3644-
return_data(vec![], &buffer, vm)
3647+
Err(create_ssl_eof_error(vm).upcast())
36453648
}
36463649
}
36473650
Err(crate::ssl::compat::SslError::WantRead) => {

crates/stdlib/src/ssl/compat.rs

Lines changed: 32 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,21 +1164,20 @@ fn handshake_write_loop(
11641164
/// TLS record header size (content_type + version + length).
11651165
const TLS_RECORD_HEADER_SIZE: usize = 5;
11661166

1167-
/// Determine how many bytes to read from the socket during a TLS handshake.
1167+
/// Read exactly one TLS record from the TCP socket.
11681168
///
11691169
/// OpenSSL reads one TLS record at a time (no read-ahead by default).
11701170
/// Rustls, however, consumes all available TCP data when fed via read_tls().
1171-
/// If application data arrives simultaneously with the final handshake record,
1172-
/// the eager read drains the TCP buffer, leaving the app data in rustls's
1173-
/// internal buffer where select() cannot see it. This causes asyncore-based
1174-
/// servers (which rely on select() for readability) to miss the data and the
1175-
/// peer times out.
1171+
/// If a close_notify or other control record arrives alongside application
1172+
/// data, the eager read drains the TCP buffer, leaving the control record in
1173+
/// rustls's internal buffer where select() cannot see it. This causes
1174+
/// asyncore-based servers (which rely on select() for readability) to miss
1175+
/// the data and the peer times out.
11761176
///
11771177
/// Fix: peek at the TCP buffer to find the first complete TLS record boundary
1178-
/// and recv() only that many bytes. Any remaining data (including application
1179-
/// data that piggybacked on the same TCP segment) stays in the kernel buffer
1180-
/// and remains visible to select().
1181-
fn handshake_recv_one_record(socket: &PySSLSocket, vm: &VirtualMachine) -> SslResult<PyObjectRef> {
1178+
/// and recv() only that many bytes. Any remaining data stays in the kernel
1179+
/// buffer and remains visible to select().
1180+
fn recv_one_tls_record(socket: &PySSLSocket, vm: &VirtualMachine) -> SslResult<PyObjectRef> {
11821181
// Peek at what is available without consuming it.
11831182
let peeked_obj = match socket.sock_peek(SSL3_RT_MAX_PLAIN_LENGTH, vm) {
11841183
Ok(d) => d,
@@ -1272,7 +1271,7 @@ fn handshake_read_data(
12721271
// record. This matches OpenSSL's default (no read-ahead) behaviour
12731272
// and keeps remaining data in the kernel buffer where select() can
12741273
// detect it.
1275-
handshake_recv_one_record(socket, vm)?
1274+
recv_one_tls_record(socket, vm)?
12761275
} else {
12771276
match socket.sock_recv(SSL3_RT_MAX_PLAIN_LENGTH, vm) {
12781277
Ok(d) => d,
@@ -1716,17 +1715,8 @@ pub(super) fn ssl_read(
17161715
}
17171716
// Blocking socket or socket with timeout: try to read more data from socket.
17181717
// Even though rustls says it doesn't want to read, more TLS records may arrive.
1719-
// This handles the case where rustls processed all buffered TLS records but
1720-
// more data is coming over the network.
1721-
let data = match socket.sock_recv(2048, vm) {
1722-
Ok(data) => data,
1723-
Err(e) => {
1724-
if is_connection_closed_error(&e, vm) {
1725-
return Err(SslError::Eof);
1726-
}
1727-
return Err(SslError::Py(e));
1728-
}
1729-
};
1718+
// Use single-record reading to avoid consuming close_notify alongside data.
1719+
let data = recv_one_tls_record(socket, vm)?;
17301720

17311721
let bytes_read = data
17321722
.clone()
@@ -2128,28 +2118,27 @@ fn ssl_ensure_data_available(
21282118
// else: non-blocking socket (timeout=0) or blocking socket (timeout=None) - skip select
21292119
}
21302120

2131-
let data = match socket.sock_recv(2048, vm) {
2132-
Ok(data) => data,
2133-
Err(e) => {
2134-
if is_blocking_io_error(&e, vm) {
2135-
return Err(SslError::WantRead);
2136-
}
2137-
// Before returning socket error, check if rustls already has a queued TLS alert
2138-
// This mirrors CPython/OpenSSL behavior: SSL errors take precedence over socket errors
2139-
// On Windows, TCP RST may arrive before we read the alert, but rustls may have
2140-
// already received and buffered the alert from a previous read
2141-
if let Err(rustls_err) = conn.process_new_packets() {
2142-
return Err(SslError::from_rustls(rustls_err));
2143-
}
2144-
// In SSL context, connection closed errors (ECONNABORTED, ECONNRESET) indicate
2145-
// unexpected connection termination - the peer closed without proper TLS shutdown.
2146-
// This is semantically equivalent to "EOF occurred in violation of protocol"
2147-
// because no close_notify alert was received.
2148-
// On Windows, TCP RST can arrive before we read the TLS alert, causing these errors.
2149-
if is_connection_closed_error(&e, vm) {
2150-
return Err(SslError::Eof);
2121+
// Read one TLS record at a time for non-BIO sockets (matching
2122+
// OpenSSL's default no-read-ahead behaviour). This prevents
2123+
// consuming a close_notify that arrives alongside application data,
2124+
// keeping it in the kernel buffer where select() can detect it.
2125+
let data = if !is_bio {
2126+
recv_one_tls_record(socket, vm)?
2127+
} else {
2128+
match socket.sock_recv(2048, vm) {
2129+
Ok(data) => data,
2130+
Err(e) => {
2131+
if is_blocking_io_error(&e, vm) {
2132+
return Err(SslError::WantRead);
2133+
}
2134+
if let Err(rustls_err) = conn.process_new_packets() {
2135+
return Err(SslError::from_rustls(rustls_err));
2136+
}
2137+
if is_connection_closed_error(&e, vm) {
2138+
return Err(SslError::Eof);
2139+
}
2140+
return Err(SslError::Py(e));
21512141
}
2152-
return Err(SslError::Py(e));
21532142
}
21542143
};
21552144

0 commit comments

Comments
 (0)