From a56214ff0e3107a84695aef5b898225ed280abde Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Sat, 26 Sep 2026 18:05:01 +0900 Subject: [PATCH 1/2] Wait for blocking SSLSocket operations through the fiber scheduler Blocking connect, read and write waited on their own selector, which stalled every other fiber on the thread. Under async, a handshake between two fibers on one thread hung (falcon's ssl_server.rb test). MRI waits in rb_io_wait, which hands off to the scheduler. Now we call the scheduler's io_wait too, when there is one. --- .../java/org/jruby/ext/openssl/SSLSocket.java | 48 ++++++++++++- test/ssl/test_ssl.rb | 69 +++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/jruby/ext/openssl/SSLSocket.java b/src/main/java/org/jruby/ext/openssl/SSLSocket.java index c43b1f45..2bb37940 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLSocket.java +++ b/src/main/java/org/jruby/ext/openssl/SSLSocket.java @@ -89,7 +89,11 @@ private enum CallSiteIndex { sync_w("sync="), flush("flush"), // ssl_context - verify_mode("verify_mode"); + verify_mode("verify_mode"), + // Fiber and its scheduler + _respond_to_current_scheduler("current_scheduler"), + current_scheduler("current_scheduler"), + io_wait("io_wait"); final String method; @@ -550,9 +554,16 @@ private Object waitSelect(final int operations, final boolean blocking, final bo if ( ! channel.isSelectable() ) return Boolean.TRUE; final Ruby runtime = getRuntime(); - final RubyThread thread = runtime.getCurrentContext().getThread(); + final ThreadContext context = runtime.getCurrentContext(); + final RubyThread thread = context.getThread(); channel.configureBlocking(false); + + if (blocking) { + IRubyObject scheduler = current_scheduler(context); + if (!scheduler.isNil()) return scheduler_io_wait(context, scheduler, operations); + } + final Selector selector = runtime.getSelectorPool().get(); SelectionKey key = null; @@ -650,6 +661,39 @@ public void wakeup() { } } + private IRubyObject current_scheduler(final ThreadContext context) { + // Fiber.current_scheduler if Fiber.respond_to?(:current_scheduler) + // (JRuby 9.4.5+, and missing with -Xexperimental.fiber.scheduler=false) + final IRubyObject fiber = context.runtime.getObject().getConstantAt("Fiber"); + final CallSite[] sites = getMetaClass().getExtraCallSites(); + if (sites == null) return fallback_current_scheduler(context, fiber); + IRubyObject respond = callSite(sites, CallSiteIndex._respond_to_current_scheduler).call(context, fiber, fiber, context.runtime.newSymbol("current_scheduler")); + if (respond.isTrue()) { + return callSite(sites, CallSiteIndex.current_scheduler).call(context, fiber, fiber); + } + return context.nil; + } + + private static IRubyObject fallback_current_scheduler(ThreadContext context, IRubyObject fiber) { + if (fiber.respondsTo("current_scheduler")) { + return fiber.callMethod(context, "current_scheduler"); + } + return context.nil; + } + + // MRI: blocking SSL operations wait in rb_io_wait, which defers to the fiber scheduler + private boolean scheduler_io_wait(final ThreadContext context, IRubyObject scheduler, int operations) { + // scheduler.io_wait(@io, events, nil) + int events = 0; + if ((operations & SelectionKey.OP_READ) != 0) events |= 1; // IO::READABLE + if ((operations & SelectionKey.OP_WRITE) != 0) events |= 4; // IO::WRITABLE + final RubyFixnum eventsValue = context.runtime.newFixnum(events); + + final CallSite[] sites = getMetaClass().getExtraCallSites(); + if (sites == null) return scheduler.callMethod(context, "io_wait", new IRubyObject[] { io, eventsValue, context.nil }).isTrue(); + return callSite(sites, CallSiteIndex.io_wait).call(context, scheduler, scheduler, io, eventsValue, context.nil).isTrue(); + } + // return values are -1 (EOF) and >= 0 (byte counts), so any value < -1 is safe to use private static final int READ_WOULD_BLOCK_RESULT = -2; private static final int WRITE_WOULD_BLOCK_RESULT = -3; diff --git a/test/ssl/test_ssl.rb b/test/ssl/test_ssl.rb index bd0ef69b..c58fe0ce 100644 --- a/test/ssl/test_ssl.rb +++ b/test/ssl/test_ssl.rb @@ -1313,4 +1313,73 @@ def test_sysread_syswrite_raise_before_handshake end end + # Just enough of a Fiber scheduler to record io_wait calls and wait with IO.select + class RecordingScheduler + attr_reader :waits + + def initialize + @waits = [] + @readable = {} + @writable = {} + end + + def io_wait(io, events, timeout) + @waits << events + @readable[io] = Fiber.current if events & IO::READABLE != 0 + @writable[io] = Fiber.current if events & IO::WRITABLE != 0 + Fiber.yield + events + ensure + @readable.delete(io) + @writable.delete(io) + end + + def fiber(&block) + Fiber.new(blocking: false, &block).tap(&:resume) + end + + def close + until @readable.empty? && @writable.empty? + readable, writable = IO.select(@readable.keys, @writable.keys) + (readable.map(&@readable) | writable.map(&@writable)).each(&:resume) + end + end + + def kernel_sleep(duration = nil) raise NotImplementedError end + def block(blocker, timeout = nil) raise NotImplementedError end + def unblock(blocker, fiber) raise NotImplementedError end + end + + def test_blocking_operations_wait_through_fiber_scheduler + assert_waits_through_fiber_scheduler(OpenSSL::SSL::SSLSocket) + end + + def test_blocking_operations_wait_through_fiber_scheduler_in_subclass + assert_waits_through_fiber_scheduler(Class.new(OpenSSL::SSL::SSLSocket)) + end + + def assert_waits_through_fiber_scheduler(socket_class) + omit "no Fiber scheduler" unless Fiber.respond_to?(:set_scheduler) + + start_server0(PORT, OpenSSL::SSL::VERIFY_NONE, true) do |server, port| + scheduler = RecordingScheduler.new + line = nil + Thread.new do + sock = TCPSocket.new("127.0.0.1", port) + Fiber.set_scheduler(scheduler) + Fiber.schedule do + ssl = socket_class.new(sock) + ssl.connect + ssl.puts "hello" + line = ssl.gets + ssl.close + end + Fiber.set_scheduler(nil) # closes the scheduler, running it until the fiber is done + end.join + + assert_equal "hello\n", line + assert_not_empty scheduler.waits + end + end + end From f0ba54273198e54fb86bc8a07c92595cb6d440f7 Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Mon, 28 Sep 2026 10:14:54 +0900 Subject: [PATCH 2/2] Call Fiber and the scheduler directly instead of through extra call sites The extra call sites are for the socket's own objects (self, the io, the ssl_context), not for outside receivers like Fiber or a fiber scheduler. Use plain respondsTo and callMethod for those. --- .../java/org/jruby/ext/openssl/SSLSocket.java | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/jruby/ext/openssl/SSLSocket.java b/src/main/java/org/jruby/ext/openssl/SSLSocket.java index 2bb37940..f1cc9cd4 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLSocket.java +++ b/src/main/java/org/jruby/ext/openssl/SSLSocket.java @@ -89,11 +89,7 @@ private enum CallSiteIndex { sync_w("sync="), flush("flush"), // ssl_context - verify_mode("verify_mode"), - // Fiber and its scheduler - _respond_to_current_scheduler("current_scheduler"), - current_scheduler("current_scheduler"), - io_wait("io_wait"); + verify_mode("verify_mode"); final String method; @@ -661,23 +657,11 @@ public void wakeup() { } } - private IRubyObject current_scheduler(final ThreadContext context) { - // Fiber.current_scheduler if Fiber.respond_to?(:current_scheduler) - // (JRuby 9.4.5+, and missing with -Xexperimental.fiber.scheduler=false) + // Fiber.current_scheduler if Fiber.respond_to?(:current_scheduler) + // (defined by default since JRuby 10; on 9.4.5+ only with -Xexperimental.fiber.scheduler) + private static IRubyObject current_scheduler(final ThreadContext context) { final IRubyObject fiber = context.runtime.getObject().getConstantAt("Fiber"); - final CallSite[] sites = getMetaClass().getExtraCallSites(); - if (sites == null) return fallback_current_scheduler(context, fiber); - IRubyObject respond = callSite(sites, CallSiteIndex._respond_to_current_scheduler).call(context, fiber, fiber, context.runtime.newSymbol("current_scheduler")); - if (respond.isTrue()) { - return callSite(sites, CallSiteIndex.current_scheduler).call(context, fiber, fiber); - } - return context.nil; - } - - private static IRubyObject fallback_current_scheduler(ThreadContext context, IRubyObject fiber) { - if (fiber.respondsTo("current_scheduler")) { - return fiber.callMethod(context, "current_scheduler"); - } + if (fiber.respondsTo("current_scheduler")) return fiber.callMethod(context, "current_scheduler"); return context.nil; } @@ -687,11 +671,9 @@ private boolean scheduler_io_wait(final ThreadContext context, IRubyObject sched int events = 0; if ((operations & SelectionKey.OP_READ) != 0) events |= 1; // IO::READABLE if ((operations & SelectionKey.OP_WRITE) != 0) events |= 4; // IO::WRITABLE - final RubyFixnum eventsValue = context.runtime.newFixnum(events); - final CallSite[] sites = getMetaClass().getExtraCallSites(); - if (sites == null) return scheduler.callMethod(context, "io_wait", new IRubyObject[] { io, eventsValue, context.nil }).isTrue(); - return callSite(sites, CallSiteIndex.io_wait).call(context, scheduler, scheduler, io, eventsValue, context.nil).isTrue(); + IRubyObject[] args = { io, context.runtime.newFixnum(events), context.nil }; + return scheduler.callMethod(context, "io_wait", args).isTrue(); } // return values are -1 (EOF) and >= 0 (byte counts), so any value < -1 is safe to use