From 5a07e8e77e5646f5016b6f9909ae3931f8286f1d Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 11:12:13 +0900 Subject: [PATCH 1/6] simplify test_ftplib --- Lib/test/test_ftplib.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/Lib/test/test_ftplib.py b/Lib/test/test_ftplib.py index 10766bc9540..87ae1a3a5ed 100644 --- a/Lib/test/test_ftplib.py +++ b/Lib/test/test_ftplib.py @@ -593,16 +593,12 @@ def test_quit(self): def test_abort(self): self.client.abort() - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_retrbinary(self): received = [] self.client.retrbinary('retr', received.append) self.check_data(b''.join(received), RETR_DATA.encode(self.client.encoding)) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_retrbinary_rest(self): for rest in (0, 10, 20): received = [] @@ -610,14 +606,11 @@ def test_retrbinary_rest(self): self.check_data(b''.join(received), RETR_DATA[rest:].encode(self.client.encoding)) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_retrlines(self): received = [] self.client.retrlines('retr', received.append) self.check_data(''.join(received), RETR_DATA.replace('\r\n', '')) - @unittest.skip('TODO: RUSTPYTHON; weird limiting to 8192, something w/ buffering?') def test_storbinary(self): f = io.BytesIO(RETR_DATA.encode(self.client.encoding)) self.client.storbinary('stor', f) @@ -629,8 +622,6 @@ def test_storbinary(self): self.client.storbinary('stor', f, callback=lambda x: flag.append(None)) self.assertTrue(flag) - @unittest.skip('TODO: RUSTPYTHON') - # ssl_error.SSLWantReadError: The operation did not complete (read) def test_storbinary_rest(self): data = RETR_DATA.replace('\r\n', '\n').encode(self.client.encoding) f = io.BytesIO(data) @@ -639,8 +630,6 @@ def test_storbinary_rest(self): self.client.storbinary('stor', f, rest=r) self.assertEqual(self.server.handler_instance.rest, str(r)) - @unittest.skip('TODO: RUSTPYTHON') - # ssl_error.SSLWantReadError: The operation did not complete (read) def test_storlines(self): data = RETR_DATA.replace('\r\n', '\n').encode(self.client.encoding) f = io.BytesIO(data) @@ -658,21 +647,15 @@ def test_storlines(self): with warnings_helper.check_warnings(('', BytesWarning), quiet=True): self.assertRaises(TypeError, self.client.storlines, 'stor foo', f) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_nlst(self): self.client.nlst() self.assertEqual(self.client.nlst(), NLST_DATA.split('\r\n')[:-1]) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_dir(self): l = [] self.client.dir(l.append) self.assertEqual(''.join(l), LIST_DATA.replace('\r\n', '')) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_mlsd(self): list(self.client.mlsd()) list(self.client.mlsd(path='/')) @@ -859,8 +842,6 @@ def test_storlines_too_long(self): f = io.BytesIO(b'x' * self.client.maxline * 2) self.assertRaises(ftplib.Error, self.client.storlines, 'stor', f) - @unittest.skip('TODO: RUSTPYTHON') - # TimeoutError: The read operation timed out def test_encoding_param(self): encodings = ['latin-1', 'utf-8'] for encoding in encodings: @@ -1025,8 +1006,6 @@ def test_context(self): self.assertIs(sock.context, ctx) self.assertIsInstance(sock, ssl.SSLSocket) - @unittest.skip('TODO: RUSTPYTHON') - # ssl_error.SSLWantReadError: The operation did not complete (read) def test_ccc(self): self.assertRaises(ValueError, self.client.ccc) self.client.login(secure=True) From 4e3b2983cc114125189367eaa11581d1f86820a6 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 11:56:50 +0900 Subject: [PATCH 2/6] mark flaky test --- Lib/test/_test_multiprocessing.py | 2 +- Lib/test/test_ftplib.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index 1311641758b..2fc206f7d53 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -1459,7 +1459,7 @@ def _acquire_release(lock, timeout, l=None, n=1): for _ in range(n): lock.release() - @unittest.expectedFailureIf(sys.platform == "darwin", "TODO: RUSTPYTHON") + @unittest.skipIf(sys.platform == 'darwin', "TODO: RUSTPYTHON; flaky on darwin") def test_repr_rlock(self): if self.TYPE != 'processes': self.skipTest('test not appropriate for {}'.format(self.TYPE)) diff --git a/Lib/test/test_ftplib.py b/Lib/test/test_ftplib.py index 87ae1a3a5ed..2488f64a218 100644 --- a/Lib/test/test_ftplib.py +++ b/Lib/test/test_ftplib.py @@ -903,6 +903,7 @@ def retr(): retr() +@unittest.skip("TODO: RUSTPYTHON; SSL + asyncore has problem") @skipUnless(ssl, "SSL not available") @requires_subprocess() class TestTLS_FTPClassMixin(TestFTPClass): @@ -921,7 +922,7 @@ def setUp(self, encoding=DEFAULT_ENCODING): @skipUnless(ssl, "SSL not available") -@unittest.skip("TODO: RUSTPYTHON; SSL + asyncore has problem on darwin") +@unittest.skip("TODO: RUSTPYTHON; SSL + asyncore has problem") @requires_subprocess() class TestTLS_FTPClass(TestCase): """Specific TLS_FTP class tests.""" From df1d22cac03935019421804fbbf6f3a899b5b6e6 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 13:19:48 +0900 Subject: [PATCH 3/6] better ssl handshake, pending tls output --- crates/stdlib/src/ssl.rs | 278 ++++++++++++++++++++++++-------- crates/stdlib/src/ssl/compat.rs | 74 ++++++--- 2 files changed, 268 insertions(+), 84 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index c22dd303c59..d7f97d191aa 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -1902,6 +1902,7 @@ mod _ssl { pending_context: PyRwLock::new(None), client_hello_buffer: PyMutex::new(None), shutdown_state: PyMutex::new(ShutdownState::NotStarted), + pending_tls_output: PyMutex::new(Vec::new()), deferred_cert_error: Arc::new(ParkingRwLock::new(None)), }; @@ -1972,6 +1973,7 @@ mod _ssl { pending_context: PyRwLock::new(None), client_hello_buffer: PyMutex::new(None), shutdown_state: PyMutex::new(ShutdownState::NotStarted), + pending_tls_output: PyMutex::new(Vec::new()), deferred_cert_error: Arc::new(ParkingRwLock::new(None)), }; @@ -2337,6 +2339,12 @@ mod _ssl { // Shutdown state for tracking close-notify exchange #[pytraverse(skip)] shutdown_state: PyMutex, + // Pending TLS output buffer for non-blocking sockets + // Stores unsent TLS bytes when sock_send() would block + // This prevents data loss when write_tls() drains rustls' internal buffer + // but the socket cannot accept all the data immediately + #[pytraverse(skip)] + pending_tls_output: PyMutex>, // Deferred client certificate verification error (for TLS 1.3) // Stores error message if client cert verification failed during handshake // Error is raised on first I/O operation after handshake @@ -2700,7 +2708,7 @@ mod _ssl { // Helper to call socket methods, bypassing any SSL wrapper pub(crate) fn sock_recv(&self, size: usize, vm: &VirtualMachine) -> PyResult { - // In BIO mode, read from incoming BIO + // In BIO mode, read from incoming BIO (flags not supported) if let Some(ref bio) = self.incoming_bio { let bio_obj: PyObjectRef = bio.clone().into(); let read_method = bio_obj.get_attr("read", vm)?; @@ -2711,7 +2719,7 @@ mod _ssl { let socket_mod = vm.import("socket", 0)?; let socket_class = socket_mod.get_attr("socket", vm)?; - // Call socket.socket.recv(self.sock, size) + // Call socket.socket.recv(self.sock, size, flags) let recv_method = socket_class.get_attr("recv", vm)?; recv_method.call((self.sock.clone(), vm.ctx.new_int(size)), vm) } @@ -2737,6 +2745,122 @@ mod _ssl { send_method.call((self.sock.clone(), vm.ctx.new_bytes(data)), vm) } + /// Flush any pending TLS output data to the socket + /// This should be called before generating new TLS output + fn flush_pending_tls_output(&self, vm: &VirtualMachine) -> PyResult<()> { + let mut pending = self.pending_tls_output.lock(); + if pending.is_empty() { + return Ok(()); + } + + let timeout = self.get_socket_timeout(vm)?; + let is_non_blocking = timeout.map(|t| t.is_zero()).unwrap_or(false); + + let mut sent_total = 0; + while sent_total < pending.len() { + let timed_out = self.sock_wait_for_io_impl(SelectKind::Write, vm)?; + if timed_out { + // Keep unsent data in pending buffer + *pending = pending[sent_total..].to_vec(); + return Err(vm.new_os_error("Write operation timed out")); + } + + let to_send = pending[sent_total..].to_vec(); + match self.sock_send(to_send, vm) { + Ok(result) => { + let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); + if sent == 0 { + if is_non_blocking { + // Keep unsent data in pending buffer + *pending = pending[sent_total..].to_vec(); + return Err(create_ssl_want_write_error(vm).upcast()); + } + continue; + } + sent_total += sent; + } + Err(e) => { + if is_blocking_io_error(&e, vm) { + if is_non_blocking { + // Keep unsent data in pending buffer + *pending = pending[sent_total..].to_vec(); + return Err(create_ssl_want_write_error(vm).upcast()); + } + continue; + } + // Keep unsent data in pending buffer for other errors too + *pending = pending[sent_total..].to_vec(); + return Err(e); + } + } + } + + // All data sent successfully + pending.clear(); + Ok(()) + } + + /// Send TLS output data to socket, saving unsent bytes to pending buffer + /// This prevents data loss when rustls' write_tls() drains its internal buffer + /// but the socket cannot accept all the data immediately + fn send_tls_output(&self, buf: Vec, vm: &VirtualMachine) -> PyResult<()> { + if buf.is_empty() { + return Ok(()); + } + + let timeout = self.get_socket_timeout(vm)?; + let is_non_blocking = timeout.map(|t| t.is_zero()).unwrap_or(false); + + let mut sent_total = 0; + while sent_total < buf.len() { + let timed_out = self.sock_wait_for_io_impl(SelectKind::Write, vm)?; + if timed_out { + // Save unsent data to pending buffer + self.pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); + return Err(vm.new_os_error("Write operation timed out")); + } + + let to_send = buf[sent_total..].to_vec(); + match self.sock_send(to_send, vm) { + Ok(result) => { + let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); + if sent == 0 { + if is_non_blocking { + // Save unsent data to pending buffer + self.pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); + return Err(create_ssl_want_write_error(vm).upcast()); + } + continue; + } + sent_total += sent; + } + Err(e) => { + if is_blocking_io_error(&e, vm) { + if is_non_blocking { + // Save unsent data to pending buffer + self.pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); + return Err(create_ssl_want_write_error(vm).upcast()); + } + continue; + } + // Save unsent data for other errors too + self.pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); + return Err(e); + } + } + } + + Ok(()) + } + #[pymethod] fn __repr__(&self) -> String { "".to_string() @@ -3229,9 +3353,10 @@ mod _ssl { }; } - // Ensure handshake is done + // Ensure handshake is done - if not, complete it first + // This matches OpenSSL behavior where SSL_read() auto-completes handshake if !*self.handshake_done.lock() { - return Err(vm.new_value_error("Handshake not completed")); + self.do_handshake(vm)?; } // Check if connection has been shut down @@ -3272,7 +3397,7 @@ mod _ssl { }; // Use compat layer for unified read logic with proper EOF handling - // This matches CPython's SSL_read_ex() approach + // This matches SSL_read_ex() approach let mut buf = vec![0u8; len]; let read_result = { let mut conn_guard = self.connection.lock(); @@ -3360,9 +3485,10 @@ mod _ssl { return Ok(0); } - // Ensure handshake is done + // Ensure handshake is done - if not, complete it first + // This matches OpenSSL behavior where SSL_write() auto-completes handshake if !*self.handshake_done.lock() { - return Err(vm.new_value_error("Handshake not completed")); + self.do_handshake(vm)?; } // Check if connection has been shut down @@ -3413,6 +3539,9 @@ mod _ssl { self.write_pending_tls(conn, vm)?; } else { // Socket mode: flush all pending TLS data + // First, try to send any previously pending data + self.flush_pending_tls_output(vm)?; + while conn.wants_write() { let mut buf = Vec::new(); conn.write_tls(&mut buf).map_err(|e| { @@ -3420,23 +3549,8 @@ mod _ssl { })?; if !buf.is_empty() { - let timed_out = - self.sock_wait_for_io_impl(SelectKind::Write, vm)?; - if timed_out { - return Err(vm.new_os_error("Write operation timed out")); - } - - match self.sock_send(buf, vm) { - Ok(_) => {} - Err(e) => { - if is_blocking_io_error(&e, vm) { - return Err( - create_ssl_want_write_error(vm).upcast() - ); - } - return Err(e); - } - } + // Try to send TLS data, saving unsent bytes to pending buffer + self.send_tls_output(buf, vm)?; } } } @@ -3775,38 +3889,51 @@ mod _ssl { // If peer hasn't closed yet, try to read from socket if !peer_closed { - // Check if socket is in blocking mode (timeout is None) - let is_blocking = if !is_bio { + // Check socket timeout mode + let timeout_mode = if !is_bio { // Get socket timeout match self.sock.get_attr("gettimeout", vm) { Ok(method) => match method.call((), vm) { - Ok(timeout) => vm.is_none(&timeout), - Err(_) => false, + Ok(timeout) => { + if vm.is_none(&timeout) { + // timeout=None means blocking + Some(None) + } else if let Ok(t) = timeout.try_float(vm).map(|f| f.to_f64()) { + if t == 0.0 { + // timeout=0 means non-blocking + Some(Some(0.0)) + } else { + // timeout>0 means timeout mode + Some(Some(t)) + } + } else { + None + } + } + Err(_) => None, }, - Err(_) => false, + Err(_) => None, } } else { - false + None // BIO mode }; if is_bio { // In BIO mode: non-blocking read attempt - let _ = self.try_read_close_notify(conn, vm); - } else if is_blocking { - // Blocking socket mode: Return immediately without waiting for peer - // - // Reasons we don't read from socket here: - // 1. STARTTLS scenario: application data may arrive before/instead of close_notify - // - Example: client sends ENDTLS, immediately sends plain "msg 5" - // - Server's unwrap() would read "msg 5" and try to parse as TLS → FAIL - // 2. CPython's SSL_shutdown() typically returns immediately without waiting - // 3. Bidirectional shutdown is the application's responsibility - // 4. Reading from socket would consume application data incorrectly + if self.try_read_close_notify(conn, vm)? { + peer_closed = true; + } + } else if let Some(_timeout) = timeout_mode { + // All socket modes (blocking, timeout, non-blocking): + // Return immediately after sending our close_notify. // - // Therefore: Just send our close_notify and return success immediately. - // The peer's close_notify (if any) will remain in the socket buffer. + // This matches CPython/OpenSSL behavior where SSL_shutdown() + // returns after sending close_notify, allowing the app to + // close the socket without waiting for peer's close_notify. // - // Mark shutdown as complete and return the underlying socket + // Waiting for peer's close_notify can cause deadlock with + // asyncore-based servers where both sides wait for the other's + // close_notify before closing the connection. drop(conn_guard); *self.shutdown_state.lock() = ShutdownState::Completed; *self.connection.lock() = None; @@ -3814,7 +3941,9 @@ mod _ssl { } // Step 3: Check again if peer has sent close_notify (non-blocking/BIO mode only) - peer_closed = self.check_peer_closed(conn, vm)?; + if !peer_closed { + peer_closed = self.check_peer_closed(conn, vm)?; + } } drop(conn_guard); // Release lock before returning @@ -3836,6 +3965,9 @@ mod _ssl { // Helper: Write all pending TLS data (including close_notify) to outgoing buffer/BIO fn write_pending_tls(&self, conn: &mut TlsConnection, vm: &VirtualMachine) -> PyResult<()> { + // First, flush any previously pending TLS output + self.flush_pending_tls_output(vm)?; + loop { if !conn.wants_write() { break; @@ -3850,42 +3982,62 @@ mod _ssl { break; } - // Send to outgoing BIO or socket - self.sock_send(buf[..written].to_vec(), vm)?; + // Send TLS data, saving unsent bytes to pending buffer if needed + self.send_tls_output(buf[..written].to_vec(), vm)?; } Ok(()) } - // Helper: Try to read incoming data from BIO (non-blocking) + // Helper: Try to read incoming data from socket/BIO + // Returns true if peer closed connection (with or without close_notify) fn try_read_close_notify( &self, conn: &mut TlsConnection, vm: &VirtualMachine, - ) -> PyResult<()> { - // Try to read incoming data from BIO - // This is non-blocking in BIO mode - if no data, recv returns empty + ) -> PyResult { + // Try to read incoming data match self.sock_recv(SSL3_RT_MAX_PLAIN_LENGTH, vm) { Ok(bytes_obj) => { let bytes = ArgBytesLike::try_from_object(vm, bytes_obj)?; let data = bytes.borrow_buf(); - if !data.is_empty() { - // Feed data to TLS connection - let data_slice: &[u8] = data.as_ref(); - let mut cursor = std::io::Cursor::new(data_slice); - let _ = conn.read_tls(&mut cursor); - - // Process packets - let _ = conn.process_new_packets(); + if data.is_empty() { + // Empty read could mean EOF or just "no data yet" in BIO mode + if let Some(ref bio) = self.incoming_bio { + // BIO mode: check if EOF was signaled via write_eof() + let bio_obj: PyObjectRef = bio.clone().into(); + let eof_attr = bio_obj.get_attr("eof", vm)?; + let is_eof = eof_attr.try_to_bool(vm)?; + if !is_eof { + // No EOF signaled, just no data available yet + return Ok(false); + } + } + // Socket mode or BIO with EOF: peer closed connection + // This is "ragged EOF" - peer closed without close_notify + return Ok(true); } + + // Feed data to TLS connection + let data_slice: &[u8] = data.as_ref(); + let mut cursor = std::io::Cursor::new(data_slice); + let _ = conn.read_tls(&mut cursor); + + // Process packets + let _ = conn.process_new_packets(); + Ok(false) } - Err(_) => { - // No data available or error - that's OK in BIO mode + Err(e) => { + // BlockingIOError means no data yet + if is_blocking_io_error(&e, vm) { + return Ok(false); + } + // Connection reset, EOF, or other error means peer closed + // ECONNRESET, EPIPE, broken pipe, etc. + Ok(true) } } - - Ok(()) } // Helper: Check if peer has sent close_notify diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index 2168fcfc91f..72382ab6175 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -1003,6 +1003,45 @@ pub(super) fn is_blocking_io_error(err: &Py, vm: &VirtualMachin err.fast_isinstance(vm.ctx.exceptions.blocking_io_error) } +// Socket I/O Helper Functions + +/// Send all bytes to socket, handling partial sends with blocking wait +/// +/// Loops until all bytes are sent. For blocking sockets, this will wait +/// until all data is sent. For non-blocking sockets, returns WantWrite +/// if no progress can be made. +fn send_all_bytes(socket: &PySSLSocket, buf: Vec, vm: &VirtualMachine) -> SslResult<()> { + if buf.is_empty() { + return Ok(()); + } + + let mut sent_total = 0; + while sent_total < buf.len() { + let to_send = buf[sent_total..].to_vec(); + match socket.sock_send(to_send, vm) { + Ok(result) => { + let sent: usize = result + .try_to_value::(vm) + .map_err(SslError::Py)? + .try_into() + .map_err(|_| SslError::Syscall("Invalid send return value".to_string()))?; + if sent == 0 { + // No progress - would block + return Err(SslError::WantWrite); + } + sent_total += sent; + } + Err(e) => { + if is_blocking_io_error(&e, vm) { + return Err(SslError::WantWrite); + } + return Err(SslError::Py(e)); + } + } + } + Ok(()) +} + // Handshake Helper Functions /// Write TLS handshake data to socket/BIO @@ -1029,20 +1068,9 @@ fn handshake_write_loop( .map_err(SslError::Io)?; if written > 0 && !buf.is_empty() { - // Send directly without select - blocking sockets will wait automatically - // Handle BlockingIOError from non-blocking sockets - match socket.sock_send(buf, vm) { - Ok(_) => { - made_progress = true; - } - Err(e) => { - if is_blocking_io_error(&e, vm) { - // Non-blocking socket would block - return SSLWantWriteError - return Err(SslError::WantWrite); - } - return Err(SslError::Py(e)); - } - } + // Send all bytes to socket, handling partial sends + send_all_bytes(socket, buf, vm)?; + made_progress = true; } else if written == 0 { // No data written but wants_write is true - should not happen normally // Break to avoid infinite loop @@ -1160,7 +1188,7 @@ fn handle_handshake_complete( // Do NOT loop on wants_write() - avoid infinite loop/deadlock let tls_data = ssl_write_tls_records(conn)?; if !tls_data.is_empty() { - socket.sock_send(tls_data, vm).map_err(SslError::Py)?; + send_all_bytes(socket, tls_data, vm)?; } // IMPORTANT: Don't check wants_write() again! @@ -1168,12 +1196,18 @@ fn handle_handshake_complete( } } else if conn.wants_write() { // Send all pending data (e.g., TLS 1.3 NewSessionTicket) to socket + // Best-effort: WantWrite means socket buffer full, pending data will be + // sent in subsequent read/write calls. Don't fail handshake for this. while conn.wants_write() { let tls_data = ssl_write_tls_records(conn)?; if tls_data.is_empty() { break; } - socket.sock_send(tls_data, vm).map_err(SslError::Py)?; + match send_all_bytes(socket, tls_data, vm) { + Ok(()) => {} + Err(SslError::WantWrite) => break, + Err(e) => return Err(e), + } } } @@ -1304,9 +1338,7 @@ pub(super) fn ssl_do_handshake( break; } // Send to outgoing BIO - socket - .sock_send(buf[..n].to_vec(), vm) - .map_err(SslError::Py)?; + send_all_bytes(socket, buf[..n].to_vec(), vm)?; // Check if there's more to write if !conn.wants_write() { break; @@ -1396,6 +1428,7 @@ pub(super) fn ssl_read( // - Blocking sockets: sock_select() and recv() wait at kernel level (no CPU busy-wait) // - Non-blocking sockets: immediate return on first WantRead // - Deadline prevents timeout issues + loop { // Check deadline if let Some(deadline) = deadline @@ -1423,7 +1456,7 @@ pub(super) fn ssl_read( // Flush pending TLS data before continuing let tls_data = ssl_write_tls_records(conn)?; if !tls_data.is_empty() { - socket.sock_send(tls_data, vm).map_err(SslError::Py)?; + send_all_bytes(socket, tls_data, vm)?; } // After flushing, rustls may want to read again - continue loop continue; @@ -1487,7 +1520,6 @@ pub(super) fn ssl_read( } // Read and process TLS records - // This will block for blocking sockets match ssl_ensure_data_available(conn, socket, vm) { Ok(_bytes_read) => { // Successfully read and processed TLS data From 1bbd93342dcf9037f639642a6254c8f1a0b70f26 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 13:27:17 +0900 Subject: [PATCH 4/6] flush_all_pending --- crates/stdlib/src/ssl.rs | 143 ++++++++++++++++++++++++++++---- crates/stdlib/src/ssl/compat.rs | 72 ++++++++++++++-- 2 files changed, 192 insertions(+), 23 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index d7f97d191aa..fed7ca2696d 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -2344,7 +2344,7 @@ mod _ssl { // This prevents data loss when write_tls() drains rustls' internal buffer // but the socket cannot accept all the data immediately #[pytraverse(skip)] - pending_tls_output: PyMutex>, + pub(crate) pending_tls_output: PyMutex>, // Deferred client certificate verification error (for TLS 1.3) // Stores error message if client cert verification failed during handshake // Error is raised on first I/O operation after handshake @@ -2724,16 +2724,12 @@ mod _ssl { recv_method.call((self.sock.clone(), vm.ctx.new_int(size)), vm) } - pub(crate) fn sock_send( - &self, - data: Vec, - vm: &VirtualMachine, - ) -> PyResult { + pub(crate) fn sock_send(&self, data: &[u8], vm: &VirtualMachine) -> PyResult { // In BIO mode, write to outgoing BIO if let Some(ref bio) = self.outgoing_bio { let bio_obj: PyObjectRef = bio.clone().into(); let write_method = bio_obj.get_attr("write", vm)?; - return write_method.call((vm.ctx.new_bytes(data),), vm); + return write_method.call((vm.ctx.new_bytes(data.to_vec()),), vm); } // Normal socket mode @@ -2742,12 +2738,12 @@ mod _ssl { // Call socket.socket.send(self.sock, data) let send_method = socket_class.get_attr("send", vm)?; - send_method.call((self.sock.clone(), vm.ctx.new_bytes(data)), vm) + send_method.call((self.sock.clone(), vm.ctx.new_bytes(data.to_vec())), vm) } /// Flush any pending TLS output data to the socket /// This should be called before generating new TLS output - fn flush_pending_tls_output(&self, vm: &VirtualMachine) -> PyResult<()> { + pub(crate) fn flush_pending_tls_output(&self, vm: &VirtualMachine) -> PyResult<()> { let mut pending = self.pending_tls_output.lock(); if pending.is_empty() { return Ok(()); @@ -2762,11 +2758,12 @@ mod _ssl { if timed_out { // Keep unsent data in pending buffer *pending = pending[sent_total..].to_vec(); - return Err(vm.new_os_error("Write operation timed out")); + return Err( + timeout_error_msg(vm, "The write operation timed out".to_string()).upcast(), + ); } - let to_send = pending[sent_total..].to_vec(); - match self.sock_send(to_send, vm) { + match self.sock_send(&pending[sent_total..], vm) { Ok(result) => { let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); if sent == 0 { @@ -2819,11 +2816,12 @@ mod _ssl { self.pending_tls_output .lock() .extend_from_slice(&buf[sent_total..]); - return Err(vm.new_os_error("Write operation timed out")); + return Err( + timeout_error_msg(vm, "The write operation timed out".to_string()).upcast(), + ); } - let to_send = buf[sent_total..].to_vec(); - match self.sock_send(to_send, vm) { + match self.sock_send(&buf[sent_total..], vm) { Ok(result) => { let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); if sent == 0 { @@ -2861,6 +2859,56 @@ mod _ssl { Ok(()) } + /// Flush all pending TLS output data, respecting socket timeout + /// Used during handshake completion and shutdown() to ensure all data is sent + pub(crate) fn blocking_flush_all_pending(&self, vm: &VirtualMachine) -> PyResult<()> { + // Get socket timeout to respect during flush + let timeout = self.get_socket_timeout(vm)?; + + loop { + let pending_data = { + let pending = self.pending_tls_output.lock(); + if pending.is_empty() { + return Ok(()); + } + pending.clone() + }; + + // Wait for socket to be writable, respecting socket timeout + let py_socket: PyRef = self.sock.clone().try_into_value(vm)?; + let socket = py_socket + .sock() + .map_err(|e| vm.new_os_error(format!("Failed to get socket: {e}")))?; + let timed_out = sock_select(&socket, SelectKind::Write, timeout) + .map_err(|e| vm.new_os_error(format!("select failed: {e}")))?; + + if timed_out { + return Err( + timeout_error_msg(vm, "The write operation timed out".to_string()).upcast(), + ); + } + + // Try to send pending data + match self.sock_send(&pending_data, vm) { + Ok(result) => { + let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); + if sent > 0 { + let mut pending = self.pending_tls_output.lock(); + pending.drain(..sent); + } + // If sent == 0, socket wasn't ready despite select() saying so + // Continue loop to retry - this avoids infinite loops + } + Err(e) => { + if is_blocking_io_error(&e, vm) { + continue; + } + return Err(e); + } + } + } + } + #[pymethod] fn __repr__(&self) -> String { "".to_string() @@ -3513,6 +3561,13 @@ mod _ssl { let is_bio = self.is_bio_mode(); let data: &[u8] = data_bytes.as_ref(); + // CRITICAL: Flush any pending TLS data before writing new data + // This ensures TLS 1.3 Finished message reaches server before application data + // Without this, server may not be ready to process our data + if !is_bio { + self.flush_pending_tls_output(vm)?; + } + // Write data in chunks to avoid filling the internal TLS buffer // rustls has a limited internal buffer, so we need to flush periodically const CHUNK_SIZE: usize = 16384; // 16KB chunks (typical TLS record size) @@ -3529,6 +3584,10 @@ mod _ssl { writer .write_all(chunk) .map_err(|e| vm.new_os_error(format!("Write failed: {e}")))?; + // Flush to ensure data is converted to TLS records + writer + .flush() + .map_err(|e| vm.new_os_error(format!("Flush failed: {e}")))?; } written = chunk_end; @@ -3869,8 +3928,35 @@ mod _ssl { .as_mut() .ok_or_else(|| vm.new_value_error("Connection not established"))?; + let is_bio = self.is_bio_mode(); + // Step 1: Send our close_notify if not already sent if current_state == ShutdownState::NotStarted { + // First, flush ALL pending TLS data BEFORE sending close_notify + // This is CRITICAL - close_notify must come AFTER all application data + // Otherwise data loss occurs when peer receives close_notify first + + // Step 1a: Flush any pending TLS records from rustls internal buffer + // This ensures all application data is converted to TLS records + while conn.wants_write() { + let mut buf = Vec::new(); + conn.write_tls(&mut buf) + .map_err(|e| vm.new_os_error(format!("TLS write failed: {e}")))?; + if !buf.is_empty() { + self.send_tls_output(buf, vm)?; + } + } + + // Step 1b: Flush pending_tls_output buffer to socket + if !is_bio { + // Socket mode: blocking flush to ensure data order + // Must complete before sending close_notify + self.blocking_flush_all_pending(vm)?; + } else { + // BIO mode: non-blocking flush (caller handles pending data) + let _ = self.flush_pending_tls_output(vm); + } + conn.send_close_notify(); // Write close_notify to outgoing buffer/BIO @@ -3881,7 +3967,6 @@ mod _ssl { } // Step 2: Try to read and process peer's close_notify - let is_bio = self.is_bio_mode(); // First check if we already have peer's close_notify // This can happen if it was received during a previous read() call @@ -3923,7 +4008,7 @@ mod _ssl { if self.try_read_close_notify(conn, vm)? { peer_closed = true; } - } else if let Some(_timeout) = timeout_mode { + } else if let Some(timeout) = timeout_mode { // All socket modes (blocking, timeout, non-blocking): // Return immediately after sending our close_notify. // @@ -3934,7 +4019,30 @@ mod _ssl { // Waiting for peer's close_notify can cause deadlock with // asyncore-based servers where both sides wait for the other's // close_notify before closing the connection. + + // Ensure all pending TLS data is sent before returning + // This prevents data loss when rustls drains its buffer + // but the socket couldn't accept all data immediately drop(conn_guard); + + // Respect socket timeout settings for flushing pending TLS data + match timeout { + Some(0.0) => { + // Non-blocking: best-effort flush, ignore errors + // to avoid deadlock with asyncore-based servers + let _ = self.flush_pending_tls_output(vm); + } + Some(_t) => { + // Timeout mode: use flush with socket's timeout + // Errors (including timeout) are propagated to caller + self.flush_pending_tls_output(vm)?; + } + None => { + // Blocking mode: wait until all pending data is sent + self.blocking_flush_all_pending(vm)?; + } + } + *self.shutdown_state.lock() = ShutdownState::Completed; *self.connection.lock() = None; return Ok(self.sock.clone()); @@ -3966,6 +4074,7 @@ mod _ssl { // Helper: Write all pending TLS data (including close_notify) to outgoing buffer/BIO fn write_pending_tls(&self, conn: &mut TlsConnection, vm: &VirtualMachine) -> PyResult<()> { // First, flush any previously pending TLS output + // Must succeed before sending new data to maintain order self.flush_pending_tls_output(vm)?; loop { diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index 72382ab6175..bb268bb35a5 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -1011,14 +1011,17 @@ pub(super) fn is_blocking_io_error(err: &Py, vm: &VirtualMachin /// until all data is sent. For non-blocking sockets, returns WantWrite /// if no progress can be made. fn send_all_bytes(socket: &PySSLSocket, buf: Vec, vm: &VirtualMachine) -> SslResult<()> { + // First, flush any previously pending TLS data + // Must succeed before sending new data to maintain order + socket.flush_pending_tls_output(vm).map_err(SslError::Py)?; + if buf.is_empty() { return Ok(()); } let mut sent_total = 0; while sent_total < buf.len() { - let to_send = buf[sent_total..].to_vec(); - match socket.sock_send(to_send, vm) { + match socket.sock_send(&buf[sent_total..], vm) { Ok(result) => { let sent: usize = result .try_to_value::(vm) @@ -1026,15 +1029,29 @@ fn send_all_bytes(socket: &PySSLSocket, buf: Vec, vm: &VirtualMachine) -> Ss .try_into() .map_err(|_| SslError::Syscall("Invalid send return value".to_string()))?; if sent == 0 { - // No progress - would block + // No progress - save unsent bytes to pending buffer + socket + .pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); return Err(SslError::WantWrite); } sent_total += sent; } Err(e) => { if is_blocking_io_error(&e, vm) { + // Save unsent bytes to pending buffer + socket + .pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); return Err(SslError::WantWrite); } + // For other errors, also save unsent bytes + socket + .pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); return Err(SslError::Py(e)); } } @@ -1056,6 +1073,10 @@ fn handshake_write_loop( ) -> SslResult { let mut made_progress = false; + // Flush any previously pending TLS data before generating new output + // Must succeed before sending new data to maintain order + socket.flush_pending_tls_output(vm).map_err(SslError::Py)?; + while conn.wants_write() || force_initial_write { if force_initial_write && !conn.wants_write() { // No data to write on first iteration - break to avoid infinite loop @@ -1211,6 +1232,15 @@ fn handle_handshake_complete( } } + // CRITICAL: Ensure all pending TLS data is sent before returning + // TLS 1.3 Finished must reach server before handshake is considered complete + // Without this, server may not process application data + if !socket.is_bio_mode() { + socket + .blocking_flush_all_pending(vm) + .map_err(SslError::Py)?; + } + Ok(true) } @@ -1283,11 +1313,14 @@ pub(super) fn ssl_do_handshake( // Send close_notify on error if !is_bio { conn.send_close_notify(); - // Actually send the close_notify alert + // Flush any pending TLS data before sending close_notify + let _ = socket.flush_pending_tls_output(vm); + // Actually send the close_notify alert using send_all_bytes + // for proper partial send handling and retry logic if let Ok(alert_data) = ssl_write_tls_records(conn) && !alert_data.is_empty() { - let _ = socket.sock_send(alert_data, vm); + let _ = send_all_bytes(socket, alert_data, vm); } } @@ -1453,11 +1486,38 @@ pub(super) fn ssl_read( // Check if connection needs to write data first (e.g., TLS key update, renegotiation) // This mirrors the handshake logic which checks both wants_read() and wants_write() if conn.wants_write() && !is_bio { + // Check deadline BEFORE attempting flush + if let Some(deadline) = deadline + && std::time::Instant::now() >= deadline + { + return Err(SslError::Timeout( + "The read operation timed out".to_string(), + )); + } + // Flush pending TLS data before continuing let tls_data = ssl_write_tls_records(conn)?; if !tls_data.is_empty() { - send_all_bytes(socket, tls_data, vm)?; + // Use best-effort send - don't fail READ just because WRITE couldn't complete + match send_all_bytes(socket, tls_data, vm) { + Ok(()) => {} + Err(SslError::WantWrite) => { + // Socket buffer full - acceptable during READ operation + // Pending data will be sent on next write/read call + } + Err(e) => return Err(e), + } + } + + // Check deadline AFTER flush attempt + if let Some(deadline) = deadline + && std::time::Instant::now() >= deadline + { + return Err(SslError::Timeout( + "The read operation timed out".to_string(), + )); } + // After flushing, rustls may want to read again - continue loop continue; } From 8baade604b546d0308215882b6187093e3f2f125 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 16:30:27 +0900 Subject: [PATCH 5/6] deadline --- crates/stdlib/src/ssl.rs | 52 +++++++++++++++++++++++++-------- crates/stdlib/src/ssl/compat.rs | 49 +++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index fed7ca2696d..5a219baaf7f 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -2724,6 +2724,8 @@ mod _ssl { recv_method.call((self.sock.clone(), vm.ctx.new_int(size)), vm) } + /// Socket send - just sends data, caller must handle pending flush + /// Use flush_pending_tls_output before this if ordering is important pub(crate) fn sock_send(&self, data: &[u8], vm: &VirtualMachine) -> PyResult { // In BIO mode, write to outgoing BIO if let Some(ref bio) = self.outgoing_bio { @@ -2742,19 +2744,45 @@ mod _ssl { } /// Flush any pending TLS output data to the socket - /// This should be called before generating new TLS output - pub(crate) fn flush_pending_tls_output(&self, vm: &VirtualMachine) -> PyResult<()> { + /// Optional deadline parameter allows respecting a read deadline during flush + pub(crate) fn flush_pending_tls_output( + &self, + vm: &VirtualMachine, + deadline: Option, + ) -> PyResult<()> { let mut pending = self.pending_tls_output.lock(); if pending.is_empty() { return Ok(()); } - let timeout = self.get_socket_timeout(vm)?; - let is_non_blocking = timeout.map(|t| t.is_zero()).unwrap_or(false); + let socket_timeout = self.get_socket_timeout(vm)?; + let is_non_blocking = socket_timeout.map(|t| t.is_zero()).unwrap_or(false); let mut sent_total = 0; while sent_total < pending.len() { - let timed_out = self.sock_wait_for_io_impl(SelectKind::Write, vm)?; + // Calculate timeout: use deadline if provided, otherwise use socket timeout + let timeout_to_use = if let Some(dl) = deadline { + let now = std::time::Instant::now(); + if now >= dl { + // Deadline already passed + *pending = pending[sent_total..].to_vec(); + return Err( + timeout_error_msg(vm, "The operation timed out".to_string()).upcast() + ); + } + Some(dl - now) + } else { + socket_timeout + }; + + // Use sock_select directly with calculated timeout + let py_socket: PyRef = self.sock.clone().try_into_value(vm)?; + let socket = py_socket + .sock() + .map_err(|e| vm.new_os_error(format!("Failed to get socket: {e}")))?; + let timed_out = sock_select(&socket, SelectKind::Write, timeout_to_use) + .map_err(|e| vm.new_os_error(format!("select failed: {e}")))?; + if timed_out { // Keep unsent data in pending buffer *pending = pending[sent_total..].to_vec(); @@ -2888,7 +2916,7 @@ mod _ssl { ); } - // Try to send pending data + // Try to send pending data (use raw to avoid recursion) match self.sock_send(&pending_data, vm) { Ok(result) => { let sent: usize = result.try_to_value::(vm)?.try_into().unwrap_or(0); @@ -3565,7 +3593,7 @@ mod _ssl { // This ensures TLS 1.3 Finished message reaches server before application data // Without this, server may not be ready to process our data if !is_bio { - self.flush_pending_tls_output(vm)?; + self.flush_pending_tls_output(vm, None)?; } // Write data in chunks to avoid filling the internal TLS buffer @@ -3599,7 +3627,7 @@ mod _ssl { } else { // Socket mode: flush all pending TLS data // First, try to send any previously pending data - self.flush_pending_tls_output(vm)?; + self.flush_pending_tls_output(vm, None)?; while conn.wants_write() { let mut buf = Vec::new(); @@ -3954,7 +3982,7 @@ mod _ssl { self.blocking_flush_all_pending(vm)?; } else { // BIO mode: non-blocking flush (caller handles pending data) - let _ = self.flush_pending_tls_output(vm); + let _ = self.flush_pending_tls_output(vm, None); } conn.send_close_notify(); @@ -4030,12 +4058,12 @@ mod _ssl { Some(0.0) => { // Non-blocking: best-effort flush, ignore errors // to avoid deadlock with asyncore-based servers - let _ = self.flush_pending_tls_output(vm); + let _ = self.flush_pending_tls_output(vm, None); } Some(_t) => { // Timeout mode: use flush with socket's timeout // Errors (including timeout) are propagated to caller - self.flush_pending_tls_output(vm)?; + self.flush_pending_tls_output(vm, None)?; } None => { // Blocking mode: wait until all pending data is sent @@ -4075,7 +4103,7 @@ mod _ssl { fn write_pending_tls(&self, conn: &mut TlsConnection, vm: &VirtualMachine) -> PyResult<()> { // First, flush any previously pending TLS output // Must succeed before sending new data to maintain order - self.flush_pending_tls_output(vm)?; + self.flush_pending_tls_output(vm, None)?; loop { if !conn.wants_write() { diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index bb268bb35a5..d1d795c1e16 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -1010,10 +1010,17 @@ pub(super) fn is_blocking_io_error(err: &Py, vm: &VirtualMachin /// Loops until all bytes are sent. For blocking sockets, this will wait /// until all data is sent. For non-blocking sockets, returns WantWrite /// if no progress can be made. -fn send_all_bytes(socket: &PySSLSocket, buf: Vec, vm: &VirtualMachine) -> SslResult<()> { - // First, flush any previously pending TLS data - // Must succeed before sending new data to maintain order - socket.flush_pending_tls_output(vm).map_err(SslError::Py)?; +/// Optional deadline parameter allows respecting a read deadline during flush. +fn send_all_bytes( + socket: &PySSLSocket, + buf: Vec, + vm: &VirtualMachine, + deadline: Option, +) -> SslResult<()> { + // First, flush any previously pending TLS data with deadline + socket + .flush_pending_tls_output(vm, deadline) + .map_err(SslError::Py)?; if buf.is_empty() { return Ok(()); @@ -1021,6 +1028,17 @@ fn send_all_bytes(socket: &PySSLSocket, buf: Vec, vm: &VirtualMachine) -> Ss let mut sent_total = 0; while sent_total < buf.len() { + // Check deadline before each send attempt + if let Some(dl) = deadline + && std::time::Instant::now() >= dl + { + socket + .pending_tls_output + .lock() + .extend_from_slice(&buf[sent_total..]); + return Err(SslError::Timeout("The operation timed out".to_string())); + } + match socket.sock_send(&buf[sent_total..], vm) { Ok(result) => { let sent: usize = result @@ -1075,7 +1093,9 @@ fn handshake_write_loop( // Flush any previously pending TLS data before generating new output // Must succeed before sending new data to maintain order - socket.flush_pending_tls_output(vm).map_err(SslError::Py)?; + socket + .flush_pending_tls_output(vm, None) + .map_err(SslError::Py)?; while conn.wants_write() || force_initial_write { if force_initial_write && !conn.wants_write() { @@ -1090,7 +1110,7 @@ fn handshake_write_loop( if written > 0 && !buf.is_empty() { // Send all bytes to socket, handling partial sends - send_all_bytes(socket, buf, vm)?; + send_all_bytes(socket, buf, vm, None)?; made_progress = true; } else if written == 0 { // No data written but wants_write is true - should not happen normally @@ -1209,7 +1229,7 @@ fn handle_handshake_complete( // Do NOT loop on wants_write() - avoid infinite loop/deadlock let tls_data = ssl_write_tls_records(conn)?; if !tls_data.is_empty() { - send_all_bytes(socket, tls_data, vm)?; + send_all_bytes(socket, tls_data, vm, None)?; } // IMPORTANT: Don't check wants_write() again! @@ -1224,7 +1244,7 @@ fn handle_handshake_complete( if tls_data.is_empty() { break; } - match send_all_bytes(socket, tls_data, vm) { + match send_all_bytes(socket, tls_data, vm, None) { Ok(()) => {} Err(SslError::WantWrite) => break, Err(e) => return Err(e), @@ -1314,13 +1334,13 @@ pub(super) fn ssl_do_handshake( if !is_bio { conn.send_close_notify(); // Flush any pending TLS data before sending close_notify - let _ = socket.flush_pending_tls_output(vm); + let _ = socket.flush_pending_tls_output(vm, None); // Actually send the close_notify alert using send_all_bytes // for proper partial send handling and retry logic if let Ok(alert_data) = ssl_write_tls_records(conn) && !alert_data.is_empty() { - let _ = send_all_bytes(socket, alert_data, vm); + let _ = send_all_bytes(socket, alert_data, vm, None); } } @@ -1371,7 +1391,7 @@ pub(super) fn ssl_do_handshake( break; } // Send to outgoing BIO - send_all_bytes(socket, buf[..n].to_vec(), vm)?; + send_all_bytes(socket, buf[..n].to_vec(), vm, None)?; // Check if there's more to write if !conn.wants_write() { break; @@ -1496,15 +1516,20 @@ pub(super) fn ssl_read( } // Flush pending TLS data before continuing + // CRITICAL: Pass deadline so flush respects read timeout let tls_data = ssl_write_tls_records(conn)?; if !tls_data.is_empty() { // Use best-effort send - don't fail READ just because WRITE couldn't complete - match send_all_bytes(socket, tls_data, vm) { + match send_all_bytes(socket, tls_data, vm, deadline) { Ok(()) => {} Err(SslError::WantWrite) => { // Socket buffer full - acceptable during READ operation // Pending data will be sent on next write/read call } + Err(SslError::Timeout(_)) => { + // Timeout during flush is acceptable during READ + // Pending data stays buffered for next operation + } Err(e) => return Err(e), } } From 23436d4fc5552fa57f09654499cf055d950b0318 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 4 Jan 2026 17:32:38 +0900 Subject: [PATCH 6/6] flush pending --- crates/stdlib/src/ssl/compat.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index d1d795c1e16..00e2c8d3c32 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -1473,6 +1473,16 @@ pub(super) fn ssl_read( None // BIO mode has no deadline }; + // CRITICAL: Flush any pending TLS output before reading + // This ensures data from previous write() calls is sent before we wait for response. + // Without this, write() may leave data in pending_tls_output (if socket buffer was full), + // and read() would timeout waiting for a response that the server never received. + if !is_bio { + socket + .flush_pending_tls_output(vm, deadline) + .map_err(SslError::Py)?; + } + // Loop to handle TLS records and post-handshake messages // Matches SSL_read behavior which loops until data is available // - CPython uses OpenSSL's SSL_read which loops on SSL_ERROR_WANT_READ/WANT_WRITE