Skip to content

Commit 34cf3dd

Browse files
committed
Fix SSL read over-consuming TCP data
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 recv_one_tls_record to return Eof (not WantRead) when peek returns empty bytes, since empty peek means the peer has closed the TCP connection.
1 parent 7c0981b commit 34cf3dd

File tree

3 files changed

+41
-54
lines changed

3 files changed

+41
-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: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,18 +3629,17 @@ 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+
// If we already sent close_notify (unwrap was called),
3634+
// raise SSLZeroReturnError (bidirectional shutdown).
3635+
// Otherwise return empty bytes, which callers (asyncore,
3636+
// asyncio sslproto) interpret as EOF.
36363637
let our_shutdown_state = *self.shutdown_state.lock();
36373638
if our_shutdown_state == ShutdownState::SentCloseNotify
36383639
|| our_shutdown_state == ShutdownState::Completed
36393640
{
3640-
// We already sent close_notify, now receiving peer's → SSLZeroReturnError
36413641
Err(create_ssl_zero_return_error(vm).upcast())
36423642
} else {
3643-
// We haven't sent close_notify yet → return empty bytes
36443643
return_data(vec![], &buffer, vm)
36453644
}
36463645
}

crates/stdlib/src/ssl/compat.rs

Lines changed: 36 additions & 44 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,
@@ -1195,7 +1194,10 @@ fn handshake_recv_one_record(socket: &PySSLSocket, vm: &VirtualMachine) -> SslRe
11951194
let peeked_bytes = peeked.borrow_buf();
11961195

11971196
if peeked_bytes.is_empty() {
1198-
return Err(SslError::WantRead);
1197+
// Empty peek means the peer has closed the TCP connection (FIN).
1198+
// Non-blocking sockets would have returned EAGAIN/EWOULDBLOCK
1199+
// (caught above as WantRead), so empty bytes here always means EOF.
1200+
return Err(SslError::Eof);
11991201
}
12001202

12011203
if peeked_bytes.len() < TLS_RECORD_HEADER_SIZE {
@@ -1272,7 +1274,7 @@ fn handshake_read_data(
12721274
// record. This matches OpenSSL's default (no read-ahead) behaviour
12731275
// and keeps remaining data in the kernel buffer where select() can
12741276
// detect it.
1275-
handshake_recv_one_record(socket, vm)?
1277+
recv_one_tls_record(socket, vm)?
12761278
} else {
12771279
match socket.sock_recv(SSL3_RT_MAX_PLAIN_LENGTH, vm) {
12781280
Ok(d) => d,
@@ -1716,17 +1718,8 @@ pub(super) fn ssl_read(
17161718
}
17171719
// Blocking socket or socket with timeout: try to read more data from socket.
17181720
// 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-
};
1721+
// Use single-record reading to avoid consuming close_notify alongside data.
1722+
let data = recv_one_tls_record(socket, vm)?;
17301723

17311724
let bytes_read = data
17321725
.clone()
@@ -2128,28 +2121,27 @@ fn ssl_ensure_data_available(
21282121
// else: non-blocking socket (timeout=0) or blocking socket (timeout=None) - skip select
21292122
}
21302123

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);
2124+
// Read one TLS record at a time for non-BIO sockets (matching
2125+
// OpenSSL's default no-read-ahead behaviour). This prevents
2126+
// consuming a close_notify that arrives alongside application data,
2127+
// keeping it in the kernel buffer where select() can detect it.
2128+
let data = if !is_bio {
2129+
recv_one_tls_record(socket, vm)?
2130+
} else {
2131+
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+
if let Err(rustls_err) = conn.process_new_packets() {
2138+
return Err(SslError::from_rustls(rustls_err));
2139+
}
2140+
if is_connection_closed_error(&e, vm) {
2141+
return Err(SslError::Eof);
2142+
}
2143+
return Err(SslError::Py(e));
21512144
}
2152-
return Err(SslError::Py(e));
21532145
}
21542146
};
21552147

0 commit comments

Comments
 (0)