diff --git a/Mavenfile b/Mavenfile
index 1581b9c1..9a724fba 100644
--- a/Mavenfile
+++ b/Mavenfile
@@ -82,9 +82,12 @@ plugin :clean do
'failOnError' => 'false' )
end
-jar 'org.jruby:jruby-core', '9.2.0.0', :scope => :provided
+jruby_compile_compat = '9.2.1.0' # due load_ext can use 9.2.0.0
+jar 'org.jruby:jruby-core', jruby_compile_compat, :scope => :provided
# for invoker generated classes we need to add javax.annotation when on Java > 8
jar 'javax.annotation:javax.annotation-api', '1.3.1', :scope => :compile
+# a test dependency to provide digest and other stdlib bits, needed when loading OpenSSL in Java unit tests
+jar 'org.jruby:jruby-stdlib', jruby_compile_compat, :scope => :test
jar 'junit:junit', '[4.13.1,)', :scope => :test
# NOTE: to build on Java 11 - installing gems fails (due old jossl) with:
diff --git a/pom.xml b/pom.xml
index 3b8009dc..7e85e21c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -98,7 +98,7 @@ DO NOT MODIFY - GENERATED CODE
org.jruby
jruby-core
- 9.2.0.0
+ 9.2.1.0
provided
@@ -107,6 +107,12 @@ DO NOT MODIFY - GENERATED CODE
1.3.1
compile
+
+ org.jruby
+ jruby-stdlib
+ 9.2.1.0
+ test
+
junit
junit
@@ -275,6 +281,7 @@ DO NOT MODIFY - GENERATED CODE
1.8
1.8
+ 8
UTF-8
true
true
diff --git a/src/main/java/org/jruby/ext/openssl/SSLSocket.java b/src/main/java/org/jruby/ext/openssl/SSLSocket.java
index 0a2f801e..51bb3e23 100644
--- a/src/main/java/org/jruby/ext/openssl/SSLSocket.java
+++ b/src/main/java/org/jruby/ext/openssl/SSLSocket.java
@@ -141,14 +141,15 @@ private static CallSite callSite(final CallSite[] sites, final CallSiteIndex ind
return sites[ index.ordinal() ];
}
+ private static final ByteBuffer EMPTY_DATA = ByteBuffer.allocate(0).asReadOnlyBuffer();
+
private SSLContext sslContext;
private SSLEngine engine;
private RubyIO io;
- private ByteBuffer appReadData;
- private ByteBuffer netReadData;
- private ByteBuffer netWriteData;
- private final ByteBuffer dummy = ByteBuffer.allocate(0); // could be static
+ ByteBuffer appReadData;
+ ByteBuffer netReadData;
+ ByteBuffer netWriteData;
private boolean initialHandshake = false;
private transient long initializeTime;
@@ -539,8 +540,18 @@ public void wakeup() {
}
}
- private static final int READ_WOULD_BLOCK_RESULT = Integer.MIN_VALUE + 1;
- private static final int WRITE_WOULD_BLOCK_RESULT = Integer.MIN_VALUE + 2;
+ // Legitimate return values are -1 (EOF) and >= 0 (byte counts), so any value < -1 is safely in sentinel territory.
+ private static final int READ_WOULD_BLOCK_RESULT = -2;
+ private static final int WRITE_WOULD_BLOCK_RESULT = -3;
+
+ private static boolean isWouldBlockResult(final int result) {
+ return result < -1;
+ }
+
+ private RubySymbol wouldBlockSymbol(final int result) {
+ assert isWouldBlockResult(result) : "unexpected result: " + result;
+ return getRuntime().newSymbol(result == READ_WOULD_BLOCK_RESULT ? "wait_readable" : "wait_writable");
+ }
private static void readWouldBlock(final Ruby runtime, final boolean exception, final int[] result) {
if ( exception ) throw newSSLErrorWaitReadable(runtime, "read would block");
@@ -552,10 +563,6 @@ private static void writeWouldBlock(final Ruby runtime, final boolean exception,
result[0] = WRITE_WOULD_BLOCK_RESULT;
}
- private void doHandshake(final boolean blocking) throws IOException {
- doHandshake(blocking, true);
- }
-
// might return :wait_readable | :wait_writable in case (true, false)
private IRubyObject doHandshake(final boolean blocking, final boolean exception) throws IOException {
while (true) {
@@ -577,7 +584,11 @@ private IRubyObject doHandshake(final boolean blocking, final boolean exception)
doTasks();
break;
case NEED_UNWRAP:
- if (readAndUnwrap(blocking) == -1 && handshakeStatus != SSLEngineResult.HandshakeStatus.FINISHED) {
+ int unwrapResult = readAndUnwrap(blocking, exception);
+ if (isWouldBlockResult(unwrapResult)) {
+ return wouldBlockSymbol(unwrapResult);
+ }
+ if (unwrapResult == -1 && handshakeStatus != SSLEngineResult.HandshakeStatus.FINISHED) {
throw new SSLHandshakeException("Socket closed");
}
// during initialHandshake, calling readAndUnwrap that results UNDERFLOW does not mean writable.
@@ -613,7 +624,7 @@ private IRubyObject doHandshake(final boolean blocking, final boolean exception)
private void doWrap(final boolean blocking) throws IOException {
netWriteData.clear();
- SSLEngineResult result = engine.wrap(dummy, netWriteData);
+ SSLEngineResult result = engine.wrap(EMPTY_DATA.duplicate(), netWriteData);
netWriteData.flip();
handshakeStatus = result.getHandshakeStatus();
status = result.getStatus();
@@ -688,7 +699,9 @@ public int write(ByteBuffer src, boolean blocking) throws SSLException, IOExcept
if ( netWriteData.hasRemaining() ) {
flushData(blocking);
}
- netWriteData.clear();
+ // use compact() to preserve any encrypted bytes that flushData could not send (non-blocking partial write)
+ // clear() would discard them, corrupting the TLS record stream:
+ netWriteData.compact();
final SSLEngineResult result = engine.wrap(src, netWriteData);
if ( result.getStatus() == SSLEngineResult.Status.CLOSED ) {
throw getRuntime().newIOError("closed SSL engine");
@@ -703,12 +716,16 @@ public int write(ByteBuffer src, boolean blocking) throws SSLException, IOExcept
}
public int read(final ByteBuffer dst, final boolean blocking) throws IOException {
+ return read(dst, blocking, true);
+ }
+
+ private int read(final ByteBuffer dst, final boolean blocking, final boolean exception) throws IOException {
if ( initialHandshake ) return 0;
if ( engine.isInboundDone() ) return -1;
if ( ! appReadData.hasRemaining() ) {
- int appBytesProduced = readAndUnwrap(blocking);
- if (appBytesProduced == -1 || appBytesProduced == 0) {
+ final int appBytesProduced = readAndUnwrap(blocking, exception);
+ if (appBytesProduced == -1 || appBytesProduced == 0 || isWouldBlockResult(appBytesProduced)) {
return appBytesProduced;
}
}
@@ -718,7 +735,15 @@ public int read(final ByteBuffer dst, final boolean blocking) throws IOException
return limit;
}
- private int readAndUnwrap(final boolean blocking) throws IOException {
+ /**
+ * @param blocking whether to block on I/O
+ * @param exception when false, returns {@link #READ_WOULD_BLOCK_RESULT} or
+ * {@link #WRITE_WOULD_BLOCK_RESULT} instead of throwing if the
+ * post-handshake processing would block
+ * @return application bytes available, -1 on EOF/close, 0 when no app data
+ * produced, or a WOULD_BLOCK sentinel when would-block with exception=false
+ */
+ private int readAndUnwrap(final boolean blocking, final boolean exception) throws IOException {
final int bytesRead = socketChannelImpl().read(netReadData);
if ( bytesRead == -1 ) {
if ( ! netReadData.hasRemaining() ||
@@ -767,7 +792,11 @@ private int readAndUnwrap(final boolean blocking) throws IOException {
handshakeStatus == SSLEngineResult.HandshakeStatus.NEED_TASK ||
handshakeStatus == SSLEngineResult.HandshakeStatus.NEED_WRAP ||
handshakeStatus == SSLEngineResult.HandshakeStatus.FINISHED ) ) {
- doHandshake(blocking);
+ IRubyObject ex = doHandshake(blocking, exception);
+ if ( ex != null ) { // :wait_readable | :wait_writable
+ // TODO needs refactoring to avoid Symbol -> int -> Symbol
+ return "wait_writable".equals(ex.asJavaString()) ? WRITE_WOULD_BLOCK_RESULT : READ_WOULD_BLOCK_RESULT;
+ }
}
return appReadData.remaining();
}
@@ -792,7 +821,7 @@ private void doShutdown() throws IOException {
}
netWriteData.clear();
try {
- engine.wrap(dummy, netWriteData); // send close (after sslEngine.closeOutbound)
+ engine.wrap(EMPTY_DATA.duplicate(), netWriteData); // send close (after sslEngine.closeOutbound)
}
catch (SSLException e) {
debug(getRuntime(), "SSLSocket.doShutdown", e);
@@ -830,6 +859,14 @@ private IRubyObject sysreadImpl(final ThreadContext context, final IRubyObject l
}
try {
+ // Flush any pending encrypted write data before reading.
+ // After write_nonblock, encrypted bytes may remain in netWriteData that haven't been sent to the server.
+ // If we read without flushing, the server may not have received the complete request
+ // (e.g. net/http POST body) and will not send a response.
+ if ( engine != null && netWriteData.hasRemaining() ) {
+ flushData(blocking);
+ }
+
// So we need to make sure to only block when there is no data left to process
if ( engine == null || ! ( appReadData.hasRemaining() || netReadData.position() > 0 ) ) {
final Object ex = waitSelect(SelectionKey.OP_READ, blocking, exception);
@@ -843,19 +880,28 @@ private IRubyObject sysreadImpl(final ThreadContext context, final IRubyObject l
if ( engine == null ) {
read = socketChannelImpl().read(dst);
} else {
- read = read(dst, blocking);
+ read = read(dst, blocking, exception);
}
- if ( read == -1 ) {
- if ( exception ) throw runtime.newEOFError();
- return context.nil;
+ switch ( read ) {
+ case -1 :
+ if ( exception ) throw runtime.newEOFError();
+ return context.nil;
+ // Post-handshake processing (e.g. TLS 1.3 NewSessionTicket) signaled would-block
+ case READ_WOULD_BLOCK_RESULT :
+ return runtime.newSymbol("wait_readable");
+ case WRITE_WOULD_BLOCK_RESULT :
+ return runtime.newSymbol("wait_writable");
}
- if ( read == 0 && status == SSLEngineResult.Status.BUFFER_UNDERFLOW ) {
- // If we didn't get any data back because we only read in a partial TLS record,
- // instead of spinning until the rest comes in, call waitSelect to either block
- // until the rest is available, or throw a "read would block" error if we are in
- // non-blocking mode.
+ if ( read == 0 && netReadData.position() == 0 ) {
+ // If we didn't get any data back and there is no buffered network data left to process,
+ // wait for more data from the network instead of spinning until it arrives.
+ // In blocking mode this blocks; in non-blocking mode it raises/returns "read would block".
+ //
+ // We check netReadData.position() rather than status == BUFFER_UNDERFLOW because readAndUnwrap
+ // may have successfully consumed a non-application record (e.g. a TLS 1.3 NewSessionTicket)
+ // leaving status == OK with zero app bytes produced and nothing left in the network buffer.
final Object ex = waitSelect(SelectionKey.OP_READ, blocking, exception);
if ( ex instanceof IRubyObject ) return (IRubyObject) ex; // :wait_readable
}
diff --git a/src/test/java/org/jruby/ext/openssl/OpenSSLHelper.java b/src/test/java/org/jruby/ext/openssl/OpenSSLHelper.java
new file mode 100644
index 00000000..5c2bf19d
--- /dev/null
+++ b/src/test/java/org/jruby/ext/openssl/OpenSSLHelper.java
@@ -0,0 +1,70 @@
+package org.jruby.ext.openssl;
+
+import org.jruby.Ruby;
+import org.jruby.runtime.ThreadContext;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+abstract class OpenSSLHelper {
+
+ protected Ruby runtime;
+
+ void setUpRuntime() throws ClassNotFoundException {
+ runtime = Ruby.newInstance();
+ loadOpenSSL(runtime);
+ }
+
+ void tearDownRuntime() {
+ if (runtime != null) runtime.tearDown(false);
+ }
+
+ protected void loadOpenSSL(final Ruby runtime) throws ClassNotFoundException {
+ // prepend lib/ so openssl.rb + jopenssl/ are loaded instead of bundled OpenSSL in jruby-stdlib
+ final String libDir = new File("lib").getAbsolutePath();
+ runtime.evalScriptlet("$LOAD_PATH.unshift '" + libDir + "'");
+ runtime.evalScriptlet("require 'openssl'");
+
+ // sanity: verify openssl was loaded from the project, not jruby-stdlib :
+ final String versionFile = new File(libDir, "jopenssl/version.rb").getAbsolutePath();
+ final String expectedVersion = runtime.evalScriptlet(
+ "File.read('" + versionFile + "').match( /.*\\sVERSION\\s*=\\s*['\"](.*)['\"]/ )[1]")
+ .toString();
+ final String loadedVersion = runtime.evalScriptlet("JOpenSSL::VERSION").toString();
+ assertEquals("OpenSSL must be loaded from project (got version " + loadedVersion +
+ "), not from jruby-stdlib", expectedVersion, loadedVersion);
+
+ // Also check the Java extension classes were resolved from the project, not jruby-stdlib :
+ final String classOrigin = runtime.getJRubyClassLoader()
+ .loadClass("org.jruby.ext.openssl.OpenSSL")
+ .getProtectionDomain().getCodeSource().getLocation().toString();
+ assertTrue("OpenSSL.class (via JRuby classloader) come from project, got: " + classOrigin,
+ classOrigin.endsWith("/pkg/classes/"));
+ }
+
+ // HELPERS
+
+ public ThreadContext currentContext() {
+ return runtime.getCurrentContext();
+ }
+
+ public static String readResource(final String resource) {
+ int n;
+ try (InputStream in = SSLSocketTest.class.getResourceAsStream(resource)) {
+ if (in == null) throw new IllegalArgumentException(resource + " not found on classpath");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to load" + resource, e);
+ }
+ }
+}
diff --git a/src/test/java/org/jruby/ext/openssl/SSLSocketTest.java b/src/test/java/org/jruby/ext/openssl/SSLSocketTest.java
new file mode 100644
index 00000000..1bbbefbf
--- /dev/null
+++ b/src/test/java/org/jruby/ext/openssl/SSLSocketTest.java
@@ -0,0 +1,172 @@
+package org.jruby.ext.openssl;
+
+import java.nio.ByteBuffer;
+
+import org.jruby.RubyArray;
+import org.jruby.RubyFixnum;
+import org.jruby.RubyInteger;
+import org.jruby.RubyString;
+import org.jruby.exceptions.RaiseException;
+import org.jruby.runtime.builtin.IRubyObject;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class SSLSocketTest extends OpenSSLHelper {
+
+ /** Loads the ssl_pair.rb script that creates a connected SSL socket pair. */
+ private String start_ssl_server_rb() { return readResource("/start_ssl_server.rb"); }
+
+ @Before
+ public void setUp() throws Exception {
+ setUpRuntime();
+ }
+
+ @After
+ public void tearDown() {
+ tearDownRuntime();
+ }
+
+ /**
+ * Real-world scenario: {@code gem push} sends a large POST body via {@code syswrite_nonblock},
+ * then reads the HTTP response via {@code sysread}.
+ *
+ * Approximates the {@code gem push} scenario:
+ *
+ * - Write 256KB via {@code syswrite_nonblock} in a loop (the net/http POST pattern)
+ * - Server reads via {@code sysread} and counts bytes
+ * - Assert: server received exactly what client sent
+ *
+ *
+ * With the old {@code clear()} bug, encrypted bytes were silently
+ * discarded during partial non-blocking writes, so the server would
+ * receive fewer bytes than sent.
+ */
+ @Test
+ public void syswriteNonblockDataIntegrity() throws Exception {
+ final RubyArray pair = (RubyArray) runtime.evalScriptlet(start_ssl_server_rb());
+ SSLSocket client = (SSLSocket) pair.entry(0).toJava(SSLSocket.class);
+ SSLSocket server = (SSLSocket) pair.entry(1).toJava(SSLSocket.class);
+
+ try {
+ // Server: read all data in a background thread, counting bytes
+ final long[] serverReceived = { 0 };
+ Thread serverReader = startServerReader(server, serverReceived);
+
+ // Client: write 256KB in 4KB chunks via syswrite_nonblock
+ byte[] chunk = new byte[4096];
+ java.util.Arrays.fill(chunk, (byte) 'P'); // P for POST body
+ RubyString payload = RubyString.newString(runtime, chunk);
+
+ long totalSent = 0;
+ for (int i = 0; i < 64; i++) { // 64 * 4KB = 256KB
+ try {
+ IRubyObject written = client.syswrite_nonblock(currentContext(), payload);
+ totalSent += ((RubyInteger) written).getLongValue();
+ } catch (RaiseException e) {
+ String rubyClass = e.getException().getMetaClass().getName();
+ if (rubyClass.contains("WaitWritable")) {
+ // Expected: non-blocking write would block — retry as blocking
+ IRubyObject written = client.syswrite(currentContext(), payload);
+ totalSent += ((RubyInteger) written).getLongValue();
+ } else {
+ System.err.println("syswrite_nonblock unexpected: " + rubyClass + ": " + e.getMessage());
+ throw e;
+ }
+ }
+ }
+ assertTrue("should have sent data", totalSent > 0);
+
+ // Close client to signal EOF, let server finish reading
+ client.callMethod(currentContext(), "close");
+ serverReader.join(10_000);
+
+ assertEquals(
+ "server must receive exactly what client sent — mismatch means encrypted bytes were lost!",
+ totalSent, serverReceived[0]
+ );
+ } finally {
+ closeQuietly(pair);
+ }
+ }
+
+ private Thread startServerReader(final SSLSocket server, final long[] serverReceived) {
+ Thread serverReader = new Thread(() -> {
+ try {
+ RubyFixnum len = RubyFixnum.newFixnum(runtime, 8192);
+ while (true) {
+ IRubyObject data = server.sysread(currentContext(), len);
+ serverReceived[0] += ((RubyString) data).getByteList().getRealSize();
+ }
+ } catch (RaiseException e) {
+ String rubyClass = e.getException().getMetaClass().getName();
+ // EOFError or IOError expected when client closes the connection
+ if (!rubyClass.equals("EOFError") && !rubyClass.equals("IOError")) {
+ System.err.println("server reader unexpected: " + rubyClass + ": " + e.getMessage());
+ e.printStackTrace(System.err);
+ }
+ }
+ });
+ serverReader.start();
+ return serverReader;
+ }
+
+ /**
+ * After saturating the TCP send buffer with {@code syswrite_nonblock},
+ * inspect {@code netWriteData} to verify the buffer is consistent.
+ */
+ @Test
+ public void syswriteNonblockNetWriteDataConsistency() {
+ final RubyArray pair = (RubyArray) runtime.evalScriptlet(start_ssl_server_rb());
+ SSLSocket client = (SSLSocket) pair.entry(0).toJava(SSLSocket.class);
+
+ try {
+ assertNotNull("netWriteData initialized after handshake", client.netWriteData);
+
+ // Saturate: server is not reading yet, so backpressure builds
+ byte[] chunk = new byte[16384];
+ java.util.Arrays.fill(chunk, (byte) 'S');
+ RubyString payload = RubyString.newString(runtime, chunk);
+
+ int successfulWrites = 0;
+ for (int i = 0; i < 200; i++) {
+ try {
+ client.syswrite_nonblock(currentContext(), payload);
+ successfulWrites++;
+ } catch (RaiseException e) {
+ String rubyClass = e.getException().getMetaClass().getName();
+ if (rubyClass.contains("WaitWritable") || rubyClass.equals("IOError")) {
+ break; // buffer saturated — expected
+ }
+ System.err.println("saturate loop unexpected: " + rubyClass + ": " + e.getMessage());
+ throw e;
+ }
+ }
+ assertTrue("at least one write should succeed", successfulWrites > 0);
+
+ // Inspect netWriteData directly
+ ByteBuffer netWriteData = client.netWriteData;
+ assertTrue("position <= limit", netWriteData.position() <= netWriteData.limit());
+ assertTrue("limit <= capacity", netWriteData.limit() <= netWriteData.capacity());
+
+ // If there are unflushed bytes, compact() preserved them
+ if (netWriteData.remaining() > 0) {
+ // The bytes should be valid TLS record data, not zeroed memory
+ byte b = netWriteData.get(netWriteData.position());
+ assertNotEquals("preserved bytes should be TLS data, not zeroed", 0, b);
+ }
+
+ } finally {
+ closeQuietly(pair);
+ }
+ }
+
+ private void closeQuietly(final RubyArray sslPair) {
+ for (int i = 0; i < sslPair.getLength(); i++) {
+ try { sslPair.entry(i).callMethod(currentContext(), "close"); }
+ catch (RaiseException e) { /* already closed */ }
+ }
+ }
+}
diff --git a/src/test/resources/start_ssl_server.rb b/src/test/resources/start_ssl_server.rb
new file mode 100644
index 00000000..2e30eb96
--- /dev/null
+++ b/src/test/resources/start_ssl_server.rb
@@ -0,0 +1,37 @@
+# Creates a connected SSL socket pair for Java unit tests.
+# Returns [client_ssl, server_ssl]
+#
+# OpenSSL extension is loaded by SSLSocketTest.setUp via OpenSSL.load(runtime).
+
+require 'socket'
+
+key = OpenSSL::PKey::RSA.new(2048)
+cert = OpenSSL::X509::Certificate.new
+cert.version = 2
+cert.serial = 1
+cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=Test')
+cert.public_key = key.public_key
+cert.not_before = Time.now
+cert.not_after = Time.now + 3600
+cert.sign(key, OpenSSL::Digest::SHA256.new)
+
+tcp_server = TCPServer.new('127.0.0.1', 0)
+port = tcp_server.local_address.ip_port
+ctx = OpenSSL::SSL::SSLContext.new
+ctx.cert = cert
+ctx.key = key
+ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
+ssl_server.start_immediately = true
+
+server_ssl = nil
+server_thread = Thread.new { server_ssl = ssl_server.accept }
+
+sock = TCPSocket.new('127.0.0.1', port)
+sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
+client_ssl = OpenSSL::SSL::SSLSocket.new(sock)
+client_ssl.sync_close = true
+client_ssl.connect
+server_thread.join(5)
+
+[client_ssl, server_ssl]
diff --git a/src/test/ruby/ssl/test_read_nonblock_tls13.rb b/src/test/ruby/ssl/test_read_nonblock_tls13.rb
new file mode 100644
index 00000000..084d2761
--- /dev/null
+++ b/src/test/ruby/ssl/test_read_nonblock_tls13.rb
@@ -0,0 +1,901 @@
+# frozen_string_literal: false
+
+require File.expand_path('test_helper', File.dirname(__FILE__))
+
+class TestReadNonblockTLS13 < TestCase
+
+ include SSLTestHelper
+
+ # ── helpers ──────────────────────────────────────────────────────────
+
+ # Set up a TLS 1.3 server where the server does NOT read, so the
+ # client's send buffer saturates. Yields |ssl, port| to the block.
+ # This forces selectNow()==0 inside doHandshake when processing
+ # TLS 1.3 post-handshake records.
+ def with_saturated_tls13_client; require 'socket'
+
+ tcp_server = TCPServer.new("127.0.0.1", 0)
+ port = tcp_server.local_address.ip_port
+
+ server_ctx = OpenSSL::SSL::SSLContext.new
+ server_ctx.cert = @svr_cert
+ server_ctx.key = @svr_key
+ server_ctx.min_version = server_ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
+
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, server_ctx)
+ ssl_server.start_immediately = true
+
+ server_ready = Queue.new
+ server_thread = Thread.new do
+ Thread.current.report_on_exception = false
+ begin
+ ssl_conn = ssl_server.accept
+ server_ready << :ready
+ # Do NOT read — the client's send buffer will fill up
+ sleep 5
+ ssl_conn.close rescue nil
+ rescue
+ server_ready << :error
+ end
+ end
+
+ begin
+ sock = TCPSocket.new("127.0.0.1", port)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
+
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ server_ready.pop # wait for server accept
+
+ # Saturate send buffer
+ chunk = "X" * 16384
+ 100.times do
+ begin
+ ssl.write_nonblock(chunk)
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ break
+ rescue
+ break
+ end
+ end
+
+ yield ssl
+ ensure
+ ssl.close rescue nil
+ sock.close rescue nil
+ tcp_server.close rescue nil
+ server_thread.kill rescue nil
+ server_thread.join(2) rescue nil
+ end
+ end
+
+ # Same as above but for TLS 1.2 (control)
+ def with_saturated_tls12_client; require 'socket'
+
+ tcp_server = TCPServer.new("127.0.0.1", 0)
+ port = tcp_server.local_address.ip_port
+
+ server_ctx = OpenSSL::SSL::SSLContext.new
+ server_ctx.cert = @svr_cert
+ server_ctx.key = @svr_key
+ server_ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION
+
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, server_ctx)
+ ssl_server.start_immediately = true
+
+ server_ready = Queue.new
+ server_thread = Thread.new do
+ Thread.current.report_on_exception = false
+ begin
+ ssl_conn = ssl_server.accept
+ server_ready << :ready
+ sleep 5
+ ssl_conn.close rescue nil
+ rescue
+ server_ready << :error
+ end
+ end
+
+ begin
+ sock = TCPSocket.new("127.0.0.1", port)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
+
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ server_ready.pop
+
+ chunk = "X" * 16384
+ 100.times do
+ begin
+ ssl.write_nonblock(chunk)
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ break
+ rescue
+ break
+ end
+ end
+
+ yield ssl
+ ensure
+ ssl.close rescue nil
+ sock.close rescue nil
+ tcp_server.close rescue nil
+ server_thread.kill rescue nil
+ server_thread.join(2) rescue nil
+ end
+ end
+
+ # ── TLS 1.3 + saturated buffer (the exact production bug scenario) ──
+
+ # Core reproducer: exception:false must return :wait_readable, not throw.
+ def test_read_nonblock_exception_false_saturated_tls13
+ with_saturated_tls13_client do |ssl|
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+ end
+ end
+
+ # exception:true must raise SSLErrorWaitReadable (not EAGAIN or IOError).
+ def test_read_nonblock_exception_true_saturated_tls13
+ with_saturated_tls13_client do |ssl|
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ raised = assert_raise(OpenSSL::SSL::SSLErrorWaitReadable) do
+ ssl.read_nonblock(1024)
+ end
+ assert_equal "read would block", raised.message
+ end
+ end
+
+ # httprb code path: read_nonblock with buffer + exception:false
+ def test_read_nonblock_with_buffer_exception_false_saturated_tls13
+ with_saturated_tls13_client do |ssl|
+ buf = ''
+ result = ssl.read_nonblock(1024, buf, exception: false)
+ assert_equal :wait_readable, result
+ end
+ end
+
+ # Calling through sysread_nonblock directly (as some gems do)
+ def test_sysread_nonblock_exception_false_saturated_tls13
+ with_saturated_tls13_client do |ssl|
+ result = ssl.send(:sysread_nonblock, 1024, exception: false)
+ assert_equal :wait_readable, result
+ end
+ end
+
+ # Multiple consecutive read_nonblock calls must all return :wait_readable
+ def test_read_nonblock_repeated_calls_saturated_tls13
+ with_saturated_tls13_client do |ssl|
+ 5.times do |i|
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result, "iteration #{i}"
+ end
+ end
+ end
+
+ # ── TLS 1.2 + saturated buffer (control — no post-handshake messages) ─
+
+ # TLS 1.2 has no post-handshake messages, so the bug path is never hit.
+ def test_read_nonblock_exception_false_saturated_tls12
+ with_saturated_tls12_client do |ssl|
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+ end
+ end
+
+ def test_read_nonblock_exception_true_saturated_tls12
+ with_saturated_tls12_client do |ssl|
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ assert_raise(OpenSSL::SSL::SSLErrorWaitReadable) do
+ ssl.read_nonblock(1024)
+ end
+ end
+ end
+
+ # ── TLS 1.3 normal (unsaturated) tests ─────────────────────────────
+
+ def test_read_nonblock_exception_false_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ 10.times do
+ result = ssl.read_nonblock(1024, exception: false)
+ assert [:wait_readable, String].any? { |t| t === result },
+ "Expected :wait_readable or String, got #{result.inspect}"
+ break if result == :wait_readable
+ end
+ ssl.close
+ end
+ end
+
+ def test_read_nonblock_exception_true_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ assert_raise(OpenSSL::SSL::SSLErrorWaitReadable) do
+ 10.times { ssl.read_nonblock(1024) }
+ end
+ ssl.close
+ end
+ end
+
+ # ── TLS 1.2 normal (unsaturated) tests (control) ───────────────────
+
+ def test_read_nonblock_exception_false_tls12
+ ctx_proc = Proc.new { |ctx| ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+ ssl.close
+ end
+ end
+
+ # ── Data round-trip: TLS 1.3 read/write still works ────────────────
+
+ def test_write_read_roundtrip_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ ssl.write("hello\n")
+ # Wait for echo data to arrive with a generous timeout
+ IO.select([ssl], nil, nil, 5)
+ # The first read_nonblock may consume a post-handshake message;
+ # retry until we get the application data.
+ data = nil
+ 10.times do
+ begin
+ data = ssl.read_nonblock(1024)
+ break
+ rescue OpenSSL::SSL::SSLErrorWaitReadable
+ IO.select([ssl], nil, nil, 2)
+ end
+ end
+ assert_equal "hello\n", data
+
+ ssl.close
+ end
+ end
+
+ def test_write_read_roundtrip_nonblock_exception_false_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ ssl.write("world\n")
+ # Wait for echo data to arrive
+ IO.select([ssl], nil, nil, 5)
+
+ # Read with exception:false — might get :wait_readable first if
+ # the engine is processing a post-handshake record.
+ result = nil
+ 10.times do
+ result = ssl.read_nonblock(1024, exception: false)
+ if result == :wait_readable
+ IO.select([ssl], nil, nil, 2)
+ next
+ end
+ break
+ end
+ assert_kind_of String, result
+ assert_equal "world\n", result
+
+ # No more data — should return :wait_readable
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+
+ ssl.close
+ end
+ end
+
+ # ── Post-write read with saturated buffer ───────────────────────────
+
+ # After a write+read cycle the post-handshake messages are consumed;
+ # a subsequent read_nonblock should simply return :wait_readable.
+ def test_read_nonblock_after_write_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ ssl.write("test\n")
+ sleep 0.1
+ begin; ssl.read_nonblock(1024); rescue OpenSSL::SSL::SSLErrorWaitReadable; end
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+ ssl.close
+ end
+ end
+
+ # ── connect_nonblock + read_nonblock ────────────────────────────────
+
+ def test_read_nonblock_with_connect_nonblock_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+
+ begin
+ ssl.connect_nonblock
+ rescue IO::WaitReadable
+ IO.select([ssl]); retry
+ rescue IO::WaitWritable
+ IO.select(nil, [ssl]); retry
+ end
+
+ assert_equal "TLSv1.3", ssl.ssl_version
+ sleep 0.05
+
+ 10.times do
+ result = ssl.read_nonblock(1024, exception: false)
+ assert [:wait_readable, String].any? { |t| t === result },
+ "Expected :wait_readable or String, got #{result.inspect}"
+ break if result == :wait_readable
+ end
+ ssl.close
+ end
+ end
+
+ # connect_nonblock + saturated buffer + read_nonblock
+ def test_read_nonblock_connect_nonblock_saturated_tls13; require 'socket'
+
+ tcp_server = TCPServer.new("127.0.0.1", 0)
+ port = tcp_server.local_address.ip_port
+
+ server_ctx = OpenSSL::SSL::SSLContext.new
+ server_ctx.cert = @svr_cert
+ server_ctx.key = @svr_key
+ server_ctx.min_version = server_ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
+
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, server_ctx)
+ ssl_server.start_immediately = true
+
+ server_ready = Queue.new
+ server_thread = Thread.new do
+ Thread.current.report_on_exception = false
+ begin
+ ssl_conn = ssl_server.accept
+ server_ready << :ready
+ sleep 5
+ ssl_conn.close rescue nil
+ rescue
+ server_ready << :error
+ end
+ end
+
+ begin
+ sock = TCPSocket.new("127.0.0.1", port)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
+
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+
+ begin
+ ssl.connect_nonblock
+ rescue IO::WaitReadable
+ IO.select([ssl]); retry
+ rescue IO::WaitWritable
+ IO.select(nil, [ssl]); retry
+ end
+
+ assert_equal "TLSv1.3", ssl.ssl_version
+ server_ready.pop
+
+ chunk = "X" * 16384
+ 100.times do
+ begin
+ ssl.write_nonblock(chunk)
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ break
+ rescue
+ break
+ end
+ end
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+ ensure
+ ssl.close rescue nil
+ sock.close rescue nil
+ tcp_server.close rescue nil
+ server_thread.kill rescue nil
+ server_thread.join(2) rescue nil
+ end
+ end
+
+ # ── Concurrent stress ──────────────────────────────────────────────
+
+ def test_read_nonblock_tls13_concurrent_stress
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ errors = Queue.new
+
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ threads = 5.times.map do |t|
+ Thread.new do
+ 20.times do |i|
+ begin
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ 5.times do
+ result = ssl.read_nonblock(1024, exception: false)
+ break if result == :wait_readable
+ end
+ rescue OpenSSL::SSL::SSLErrorWaitReadable
+ errors << "Thread #{t} iter #{i}: SSLErrorWaitReadable thrown with exception:false"
+ rescue Errno::EAGAIN
+ errors << "Thread #{t} iter #{i}: EAGAIN thrown with exception:false"
+ rescue
+ # Other errors (connection reset, etc.) are acceptable
+ ensure
+ ssl.close rescue nil
+ sock.close rescue nil
+ end
+ end
+ end
+ end
+
+ threads.each { |t| t.join(10) }
+ end
+
+ collected = []
+ collected << errors.pop until errors.empty?
+ assert collected.empty?, "Got #{collected.size} exception leaks:\n#{collected.first(5).join("\n")}"
+ end
+
+ # ── Buffered I/O: multi-chunk read_nonblock ──────────────────────
+ #
+ # Write a large payload (bigger than one TLS record ~16KB), read it
+ # back in small read_nonblock chunks. Exercises:
+ # - appReadData having leftover bytes across read() calls
+ # - netReadData having multiple TLS records
+ # - the netReadData.position()==0 guard NOT firing when there IS data
+
+ def test_multi_chunk_read_nonblock_tls13
+ large = "A" * 1024 + "\n" # each line is 1025 bytes
+ total_lines = 30 # ~30KB total, exceeds one TLS record
+ payload = large * total_lines
+
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ # Write the payload — the echo server will echo each line back
+ ssl.write(payload)
+
+ # Read it all back in small non-blocking chunks
+ received = +""
+ deadline = Time.now + 5
+ while received.bytesize < payload.bytesize && Time.now < deadline
+ begin
+ chunk = ssl.read_nonblock(1024)
+ received << chunk
+ rescue OpenSSL::SSL::SSLErrorWaitReadable
+ IO.select([ssl], nil, nil, 1)
+ end
+ end
+
+ assert_equal payload.bytesize, received.bytesize
+ assert_equal payload, received
+ ssl.close
+ end
+ end
+
+ def test_multi_chunk_read_nonblock_tls12
+ large = "A" * 1024 + "\n"
+ total_lines = 30
+ payload = large * total_lines
+
+ ctx_proc = Proc.new { |ctx| ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ ssl.write(payload)
+
+ received = +""
+ deadline = Time.now + 5
+ while received.bytesize < payload.bytesize && Time.now < deadline
+ begin
+ chunk = ssl.read_nonblock(1024)
+ received << chunk
+ rescue OpenSSL::SSL::SSLErrorWaitReadable
+ IO.select([ssl], nil, nil, 1)
+ end
+ end
+
+ assert_equal payload.bytesize, received.bytesize
+ assert_equal payload, received
+ ssl.close
+ end
+ end
+
+ # ── Buffered I/O: multi-chunk with exception:false ─────────────────
+
+ def test_multi_chunk_read_nonblock_exception_false_tls13
+ large = "B" * 1024 + "\n"
+ total_lines = 30
+ payload = large * total_lines
+
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ ssl.write(payload)
+
+ received = +""
+ deadline = Time.now + 5
+ while received.bytesize < payload.bytesize && Time.now < deadline
+ result = ssl.read_nonblock(1024, exception: false)
+ case result
+ when :wait_readable
+ IO.select([ssl], nil, nil, 1)
+ when :wait_writable
+ IO.select(nil, [ssl], nil, 1)
+ when String
+ received << result
+ end
+ end
+
+ assert_equal payload.bytesize, received.bytesize
+ assert_equal payload, received
+ ssl.close
+ end
+ end
+
+ # ── Buffered I/O: partial read_nonblock ────────────────────────────
+ #
+ # Adapted from MRI's test_read_nonblock_without_session pattern.
+ # Write data, read a small amount (leaves data in appReadData buffer),
+ # then read the rest.
+
+ def test_partial_read_nonblock_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ ssl.write("hello world\n")
+ IO.select([ssl], nil, nil, 5)
+
+ # Read just 5 bytes — the rest stays in appReadData buffer
+ first = nil
+ 10.times do
+ begin
+ first = ssl.read_nonblock(5)
+ break
+ rescue OpenSSL::SSL::SSLErrorWaitReadable
+ IO.select([ssl], nil, nil, 2)
+ end
+ end
+ assert_equal "hello", first
+
+ # Read the rest — should come from the buffer, no network I/O needed
+ rest = ssl.read_nonblock(100)
+ assert_equal " world\n", rest
+
+ # Nothing left
+ result = ssl.read_nonblock(100, exception: false)
+ assert_equal :wait_readable, result
+
+ ssl.close
+ end
+ end
+
+ # ── Buffered I/O: multiple write+read cycles ───────────────────────
+ #
+ # Adapted from MRI's test_parallel pattern (single connection version).
+ # Verifies the engine state stays clean across many exchanges after
+ # TLS 1.3 post-handshake processing.
+
+ def test_multiple_write_read_cycles_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ str = "x" * 1000 + "\n"
+ 10.times do |i|
+ ssl.puts(str)
+ response = ssl.gets
+ assert_equal str, response, "cycle #{i}: data mismatch"
+ end
+
+ ssl.close
+ end
+ end
+
+ def test_multiple_write_read_cycles_tls12
+ ctx_proc = Proc.new { |ctx| ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ str = "x" * 1000 + "\n"
+ 10.times do |i|
+ ssl.puts(str)
+ response = ssl.gets
+ assert_equal str, response, "cycle #{i}: data mismatch"
+ end
+
+ ssl.close
+ end
+ end
+
+ # ── Buffered I/O: sysread/syswrite round-trip ──────────────────────
+ #
+ # Adapted from MRI's test_sysread_and_syswrite: multiple cycles of
+ # syswrite/sysread with exact byte counts. Exercises the blocking
+ # sysreadImpl path on TLS 1.3.
+
+ def test_sysread_syswrite_tls13
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true, :ctx_proc => ctx_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ str = "x" * 100 + "\n"
+
+ # Cycle 1: basic syswrite/sysread
+ ssl.syswrite(str)
+ newstr = ssl.sysread(str.bytesize)
+ assert_equal str, newstr
+
+ # Cycle 2: sysread into a buffer
+ buf = String.new
+ ssl.syswrite(str)
+ assert_same buf, ssl.sysread(str.bytesize, buf)
+ assert_equal str, buf
+
+ # Cycle 3: another round
+ ssl.syswrite(str)
+ assert_equal str, ssl.sysread(str.bytesize)
+
+ ssl.close
+ end
+ end
+
+ # ── Buffered I/O: large payload to exercise netReadData leftovers ──
+ #
+ # The server writes a large payload in one shot. The client reads
+ # it in small read_nonblock chunks. When socketChannelImpl().read()
+ # pulls in multiple TLS records at once, netReadData has leftover
+ # bytes (position > 0) after the first unwrap. The sysreadImpl loop
+ # must continue processing (NOT call waitSelect) when netReadData
+ # still has data.
+ #
+ # This is the critical regression test for the
+ # netReadData.position()==0 guard — it must NOT wait when there's
+ # still buffered network data.
+
+ def test_large_server_write_small_client_reads_tls13
+ # Custom server_proc: read a size header, then send that many bytes
+ server_proc = Proc.new do |context, ssl|
+ begin
+ line = ssl.gets # read the request
+ if line && line.strip =~ /^SEND (\d+)$/
+ size = $1.to_i
+ data = "Z" * size + "\n"
+ ssl.write(data)
+ end
+ rescue IOError, OpenSSL::SSL::SSLError
+ ensure
+ ssl.close rescue nil
+ end
+ end
+
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true,
+ :ctx_proc => ctx_proc, :server_proc => server_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ # Ask the server to send 48KB — this will be split across multiple
+ # TLS records (~16KB each), giving us netReadData with leftover bytes.
+ expected_size = 48 * 1024
+ ssl.puts("SEND #{expected_size}")
+
+ received = +""
+ expected_total = expected_size + 1 # +1 for the trailing "\n"
+ deadline = Time.now + 5
+ while received.bytesize < expected_total && Time.now < deadline
+ result = ssl.read_nonblock(4096, exception: false)
+ case result
+ when :wait_readable
+ IO.select([ssl], nil, nil, 1)
+ when :wait_writable
+ IO.select(nil, [ssl], nil, 1)
+ when String
+ received << result
+ end
+ end
+
+ assert_equal expected_total, received.bytesize,
+ "Expected #{expected_total} bytes but got #{received.bytesize}"
+ assert_equal "Z" * expected_size + "\n", received
+ ssl.close
+ end
+ end
+
+ def test_large_server_write_small_client_reads_tls12
+ server_proc = Proc.new do |context, ssl|
+ begin
+ line = ssl.gets
+ if line && line.strip =~ /^SEND (\d+)$/
+ size = $1.to_i
+ data = "Z" * size + "\n"
+ ssl.write(data)
+ end
+ rescue IOError, OpenSSL::SSL::SSLError
+ ensure
+ ssl.close rescue nil
+ end
+ end
+
+ ctx_proc = Proc.new { |ctx| ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true,
+ :ctx_proc => ctx_proc, :server_proc => server_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.2", ssl.ssl_version
+
+ expected_size = 48 * 1024
+ ssl.puts("SEND #{expected_size}")
+
+ received = +""
+ expected_total = expected_size + 1
+ deadline = Time.now + 5
+ while received.bytesize < expected_total && Time.now < deadline
+ result = ssl.read_nonblock(4096, exception: false)
+ case result
+ when :wait_readable
+ IO.select([ssl], nil, nil, 1)
+ when :wait_writable
+ IO.select(nil, [ssl], nil, 1)
+ when String
+ received << result
+ end
+ end
+
+ assert_equal expected_total, received.bytesize
+ assert_equal "Z" * expected_size + "\n", received
+ ssl.close
+ end
+ end
+
+ # ── Wasted iteration detection ─────────────────────────────────────
+ #
+ # TLS 1.3 post-handshake record (NewSessionTicket) that produces 0 app bytes, status is OK
+ #
+ # We detect this by inspecting the internal `status` field after read_nonblock returns :wait_readable.
+ # If status is BUFFER_UNDERFLOW, the extra iteration occurred. If status is OK, sysreadImpl handled
+ # the read==0/status==OK case directly.
+ def test_internal_no_wasted_readAndUnwrap_iteration_tls13; require 'socket'
+
+ tcp_server = TCPServer.new("127.0.0.1", 0)
+ port = tcp_server.local_address.ip_port
+
+ server_ctx = OpenSSL::SSL::SSLContext.new
+ server_ctx.cert = @svr_cert
+ server_ctx.key = @svr_key
+ server_ctx.ssl_version = "TLSv1_3"
+
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, server_ctx)
+ ssl_server.start_immediately = true
+
+ server_thread = Thread.new do
+ Thread.current.report_on_exception = false
+ begin
+ conn = ssl_server.accept
+ sleep 5
+ conn.close rescue nil
+ rescue
+ end
+ end
+
+ begin
+ sock = TCPSocket.new("127.0.0.1", port)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+ assert_equal "TLSv1.3", ssl.ssl_version
+
+ # Wait for the server's NewSessionTicket to arrive on the wire
+ # after the blocking connect has finished.
+ sleep 0.1
+
+ # Access the private `status` field via Java reflection
+ java_cls = Java::OrgJrubyExtOpenssl::SSLSocket.java_class
+ status_field = java_cls.declared_field("status")
+ status_field.accessible = true
+ java_ssl = ssl.to_java(Java::OrgJrubyExtOpenssl::SSLSocket)
+
+ result = ssl.read_nonblock(1024, exception: false)
+ assert_equal :wait_readable, result
+
+ status_after = status_field.value(java_ssl).to_s
+ # If sysreadImpl properly handles read==0 with any status (not just BUFFER_UNDERFLOW),
+ # only one readAndUnwrap call is made and status stays OK.
+ assert_equal "OK", status_after,
+ "Expected status OK (single readAndUnwrap call) but got #{status_after} " \
+ "(extra wasted iteration through readAndUnwrap occurred)"
+
+ ssl.close
+ ensure
+ ssl.close rescue nil
+ sock.close rescue nil
+ tcp_server.close rescue nil
+ server_thread.kill rescue nil
+ server_thread.join(2) rescue nil
+ end
+ end if defined?(JRUBY_VERSION)
+end
diff --git a/src/test/ruby/ssl/test_write_nonblock.rb b/src/test/ruby/ssl/test_write_nonblock.rb
new file mode 100644
index 00000000..ec9a88c4
--- /dev/null
+++ b/src/test/ruby/ssl/test_write_nonblock.rb
@@ -0,0 +1,197 @@
+require File.expand_path('test_helper', File.dirname(__FILE__))
+
+class TestWriteNonblock < TestCase
+
+ include SSLTestHelper
+
+ # Reproduces the data loss: write a large payload via write_nonblock
+ # with a slow-reading server (small recv buffer), then read the response.
+ # The server echoes back the byte count it received. If bytes were lost,
+ # the count will be less than expected.
+ def test_write_nonblock_data_integrity
+ expected_size = 256 * 1024 # 256KB — large enough to overflow TCP buffers
+
+ # Custom server: reads all data until a blank line, counts bytes, sends back the count.
+ # Deliberately slow: small recv buffer + sleep between reads to create backpressure.
+ server_proc = Proc.new do |context, ssl|
+ begin
+ total = 0
+ while (line = ssl.gets)
+ break if line.strip.empty?
+ total += line.bytesize
+ end
+ ssl.write("RECEIVED #{total}\n")
+ rescue IOError, OpenSSL::SSL::SSLError => e
+ # If the TLS stream is corrupted, the server may get an error here
+ warn "Server error: #{e.class}: #{e.message}" if $VERBOSE
+ ensure
+ ssl.close rescue nil
+ end
+ end
+
+ [OpenSSL::SSL::TLS1_2_VERSION, OpenSSL::SSL::TLS1_3_VERSION].each do |tls_version|
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = tls_version }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true,
+ :ctx_proc => ctx_proc, :server_proc => server_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ # Small send buffer to increase the chance of partial non-blocking writes
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
+
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ # Build a large payload: many lines totaling expected_size bytes
+ line = "X" * 1023 + "\n" # 1024 bytes per line
+ lines_needed = expected_size / line.bytesize
+ payload = line * lines_needed
+ actual_payload_size = payload.bytesize
+
+ # Write it all using write_nonblock (as net/http does)
+ written = 0
+ while written < payload.bytesize
+ begin
+ n = ssl.write_nonblock(payload.byteslice(written, payload.bytesize - written))
+ written += n
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ IO.select(nil, [ssl], nil, 5)
+ retry
+ end
+ end
+
+ # Send terminator
+ ssl.write("\n")
+
+ # Read the response (this is where the flush-before-read matters)
+ response = nil
+ deadline = Time.now + 10
+ while Time.now < deadline
+ begin
+ response = ssl.gets
+ break if response
+ rescue IO::WaitReadable, OpenSSL::SSL::SSLErrorWaitReadable
+ IO.select([ssl], nil, nil, 5)
+ end
+ end
+
+ assert_not_nil response, "No response from server (TLS #{ssl.ssl_version})"
+ assert_match(/^RECEIVED (\d+)/, response)
+ received = response[/RECEIVED (\d+)/, 1].to_i
+ assert_equal actual_payload_size, received,
+ "Server received #{received} bytes but we sent #{actual_payload_size} " \
+ "(lost #{actual_payload_size - received} bytes) on #{ssl.ssl_version}"
+
+ ssl.close
+ end
+ end
+ end
+
+ # Simpler test: write_nonblock followed by sysread should work.
+ # This is the net/http pattern: POST body via write, then read response.
+ def test_write_nonblock_then_sysread
+ server_proc = Proc.new do |context, ssl|
+ begin
+ data = +""
+ while (line = ssl.gets)
+ break if line.strip == "END"
+ data << line
+ end
+ ssl.write("OK:#{data.bytesize}\n")
+ rescue IOError, OpenSSL::SSL::SSLError
+ ensure
+ ssl.close rescue nil
+ end
+ end
+
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true,
+ :ctx_proc => ctx_proc, :server_proc => server_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ # Write via write_nonblock
+ payload = "Y" * 50_000 + "\n"
+ written = 0
+ while written < payload.bytesize
+ begin
+ n = ssl.write_nonblock(payload.byteslice(written, payload.bytesize - written))
+ written += n
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ IO.select(nil, [ssl], nil, 5)
+ retry
+ end
+ end
+ ssl.write("END\n")
+
+ # Now read response via sysread (the net/http pattern)
+ IO.select([ssl], nil, nil, 10)
+ response = ssl.sysread(1024)
+ assert_match(/^OK:(\d+)/, response)
+ received = response[/OK:(\d+)/, 1].to_i
+ assert_equal payload.bytesize, received, "Server received #{received} bytes but sent #{payload.bytesize}"
+
+ ssl.close
+ end
+ end
+
+ # Test that multiple write_nonblock calls preserve all data even under
+ # buffer pressure (many small writes)
+ def test_many_small_write_nonblock_calls
+ server_proc = Proc.new do |context, ssl|
+ begin
+ total = 0
+ while (line = ssl.gets)
+ break if line.strip == "DONE"
+ total += line.bytesize
+ end
+ ssl.write("TOTAL:#{total}\n")
+ rescue IOError, OpenSSL::SSL::SSLError
+ ensure
+ ssl.close rescue nil
+ end
+ end
+
+ ctx_proc = Proc.new { |ctx| ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION }
+ start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true,
+ :ctx_proc => ctx_proc, :server_proc => server_proc) do |server, port|
+ sock = TCPSocket.new("127.0.0.1", port)
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
+ ssl = OpenSSL::SSL::SSLSocket.new(sock)
+ ssl.sync_close = true
+ ssl.connect
+
+ # Send 500 small lines rapidly via write_nonblock
+ line = "Z" * 200 + "\n"
+ expected_total = 0
+ 500.times do
+ written = 0
+ while written < line.bytesize
+ begin
+ n = ssl.write_nonblock(line.byteslice(written, line.bytesize - written))
+ written += n
+ rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
+ IO.select(nil, [ssl], nil, 5)
+ retry
+ end
+ end
+ expected_total += line.bytesize
+ end
+ ssl.write("DONE\n")
+
+ IO.select([ssl], nil, nil, 10)
+ response = ssl.gets
+ assert_not_nil response
+ received = response[/TOTAL:(\d+)/, 1].to_i
+ assert_equal expected_total, received, "Server received #{received} bytes but sent #{expected_total}"
+
+ ssl.close
+ end
+ end
+
+ # NOTE: the netWriteData compact-vs-clear unit test for #242 (jruby/jruby#8935) is now a
+ # Java test in SSLSocketTest — it can access package-private state directly without reflection.
+end