From 8a2004068b4e86275920f0e3e289f67db4731fd5 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Fri, 5 Dec 2025 01:49:31 +0900 Subject: [PATCH 1/5] support Ractor 1. Introduce State to store all status. 2. Store State instance to the Ractor local storage if possible 3. Make `GET_TIME` (Method object) shareable if possible 3 is supporeted Ruby 4.0 and later, so the Rator support is works only on Ruby 4.0 and later. --- lib/timeout.rb | 166 ++++++++++++++++++++++++++++--------------- test/test_timeout.rb | 20 ++++++ 2 files changed, 127 insertions(+), 59 deletions(-) diff --git a/lib/timeout.rb b/lib/timeout.rb index f173d02..2bf3e75 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -44,12 +44,107 @@ def self.handle_timeout(message) # :nodoc: end # :stopdoc: - CONDVAR = ConditionVariable.new - QUEUE = Queue.new - QUEUE_MUTEX = Mutex.new - TIMEOUT_THREAD_MUTEX = Mutex.new - @timeout_thread = nil - private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX + + # We keep a private reference so that time mocking libraries won't break + # Timeout. + GET_TIME = + if defined?(Ractor.make_shareable) + begin + Ractor.make_shareable(Process.method(:clock_gettime)) + rescue # failed on Ruby 3.4 + Process.method(:clock_gettime) + end + else + Process.method(:clock_gettime) + end + + private_constant :GET_TIME + + class State + attr_reader :condvar, :queue, :queue_mutex # shared with Timeout.timeout() + + def initialize + @condvar = ConditionVariable.new + @queue = Queue.new + @queue_mutex = Mutex.new + + @timeout_thread = nil + @timeout_thread_mutex = Mutex.new + end + + if defined?(Ractor.store_if_absent) && + defined?(Ractor.shareble?) && Ractor.shareable?(GET_TIME) + + # Ractor support if + # 1. Ractor.store_if_absent is available + # 2. Method object can be shareable (4.0~) + + Ractor.store_if_absent :timeout_gem_state do + State.new + end + + def self.instance + Ractor[:timeout_gem_state] + end + + ::Timeout::RACTOR_SUPPORT = true # for test + else + @GLOBAL_STATE = State.new + + def self.instance + @GLOBAL_STATE + end + end + + def create_timeout_thread + watcher = Thread.new do + requests = [] + while true + until @queue.empty? and !requests.empty? # wait to have at least one request + req = @queue.pop + requests << req unless req.done? + end + closest_deadline = requests.min_by(&:deadline).deadline + + now = 0.0 + @queue_mutex.synchronize do + while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and @queue.empty? + @condvar.wait(@queue_mutex, closest_deadline - now) + end + end + + requests.each do |req| + req.interrupt if req.expired?(now) + end + requests.reject!(&:done?) + end + end + + if !watcher.group.enclosed? && (!defined?(Ractor.main?) || Ractor.main?) + ThreadGroup::Default.add(watcher) + end + + watcher.name = "Timeout stdlib thread" + watcher.thread_variable_set(:"\0__detached_thread__", true) + watcher + end + + def ensure_timeout_thread_created + unless @timeout_thread&.alive? + # If the Mutex is already owned we are in a signal handler. + # In that case, just return and let the main thread create the Timeout thread. + return if @timeout_thread_mutex.owned? + + @timeout_thread_mutex.synchronize do + unless @timeout_thread&.alive? + @timeout_thread = create_timeout_thread + end + end + end + end + end + + private_constant :State class Request attr_reader :deadline @@ -91,55 +186,6 @@ def finished end private_constant :Request - def self.create_timeout_thread - watcher = Thread.new do - requests = [] - while true - until QUEUE.empty? and !requests.empty? # wait to have at least one request - req = QUEUE.pop - requests << req unless req.done? - end - closest_deadline = requests.min_by(&:deadline).deadline - - now = 0.0 - QUEUE_MUTEX.synchronize do - while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty? - CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now) - end - end - - requests.each do |req| - req.interrupt if req.expired?(now) - end - requests.reject!(&:done?) - end - end - ThreadGroup::Default.add(watcher) unless watcher.group.enclosed? - watcher.name = "Timeout stdlib thread" - watcher.thread_variable_set(:"\0__detached_thread__", true) - watcher - end - private_class_method :create_timeout_thread - - def self.ensure_timeout_thread_created - unless @timeout_thread and @timeout_thread.alive? - # If the Mutex is already owned we are in a signal handler. - # In that case, just return and let the main thread create the @timeout_thread. - return if TIMEOUT_THREAD_MUTEX.owned? - TIMEOUT_THREAD_MUTEX.synchronize do - unless @timeout_thread and @timeout_thread.alive? - @timeout_thread = create_timeout_thread - end - end - end - end - private_class_method :ensure_timeout_thread_created - - # We keep a private reference so that time mocking libraries won't break - # Timeout. - GET_TIME = Process.method(:clock_gettime) - private_constant :GET_TIME - # :startdoc: # Perform an operation in a block, raising an error if it takes longer than @@ -178,12 +224,14 @@ def self.timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ return scheduler.timeout_after(sec, klass || Error, message, &block) end - ensure_timeout_thread_created + state = State.instance + state.ensure_timeout_thread_created + perform = Proc.new do |exc| request = Request.new(Thread.current, sec, exc, message) - QUEUE_MUTEX.synchronize do - QUEUE << request - CONDVAR.signal + state.queue_mutex.synchronize do + state.queue << request + state.condvar.signal end begin return yield(sec) diff --git a/test/test_timeout.rb b/test/test_timeout.rb index e367df7..233f54e 100644 --- a/test/test_timeout.rb +++ b/test/test_timeout.rb @@ -280,4 +280,24 @@ def test_handling_enclosed_threadgroup }.join end; end + + def test_ractor + assert_separately(%w[-rtimeout -W0], <<-'end;') + r = Ractor.new do + Timeout.timeout(1) { 42 } + end.value + + assert_equal 42, r + + r = Ractor.new do + begin + Timeout.timeout(0.1) { sleep } + rescue Timeout::Error + :ok + end + end.value + + assert_equal :ok, r + end; + end if Timeout.const_defined?(:RACTOR_SUPPORT) end From cd567de4ca195885300c89a13a17096e06c979bf Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Fri, 5 Dec 2025 19:14:28 +0100 Subject: [PATCH 2/5] Minor tweaks --- lib/timeout.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/timeout.rb b/lib/timeout.rb index 2bf3e75..3ad0193 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -57,7 +57,6 @@ def self.handle_timeout(message) # :nodoc: else Process.method(:clock_gettime) end - private_constant :GET_TIME class State @@ -89,10 +88,10 @@ def self.instance ::Timeout::RACTOR_SUPPORT = true # for test else - @GLOBAL_STATE = State.new + GLOBAL_STATE = State.new def self.instance - @GLOBAL_STATE + GLOBAL_STATE end end @@ -143,7 +142,6 @@ def ensure_timeout_thread_created end end end - private_constant :State class Request From 2ca257f15788f9aa0927af2f66c3ebe55a7126c0 Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Fri, 5 Dec 2025 19:19:13 +0100 Subject: [PATCH 3/5] Fix condition and fix test to catch that broken condition --- lib/timeout.rb | 4 +--- test/test_timeout.rb | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/timeout.rb b/lib/timeout.rb index 3ad0193..47410af 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -72,7 +72,7 @@ def initialize end if defined?(Ractor.store_if_absent) && - defined?(Ractor.shareble?) && Ractor.shareable?(GET_TIME) + defined?(Ractor.shareable?) && Ractor.shareable?(GET_TIME) # Ractor support if # 1. Ractor.store_if_absent is available @@ -85,8 +85,6 @@ def initialize def self.instance Ractor[:timeout_gem_state] end - - ::Timeout::RACTOR_SUPPORT = true # for test else GLOBAL_STATE = State.new diff --git a/test/test_timeout.rb b/test/test_timeout.rb index 233f54e..3f94134 100644 --- a/test/test_timeout.rb +++ b/test/test_timeout.rb @@ -299,5 +299,5 @@ def test_ractor assert_equal :ok, r end; - end if Timeout.const_defined?(:RACTOR_SUPPORT) + end if defined?(::Ractor) && RUBY_VERSION >= '4.0' end From b9f49f93fdf94492ddca83a9d89e4c38776150aa Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Fri, 5 Dec 2025 19:21:37 +0100 Subject: [PATCH 4/5] Fix logic for Ractor support * Fix indentation to stay a multiple of 2 spaces. --- lib/timeout.rb | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/lib/timeout.rb b/lib/timeout.rb index 47410af..eab1a1b 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -45,8 +45,7 @@ def self.handle_timeout(message) # :nodoc: # :stopdoc: - # We keep a private reference so that time mocking libraries won't break - # Timeout. + # We keep a private reference so that time mocking libraries won't break Timeout. GET_TIME = if defined?(Ractor.make_shareable) begin @@ -71,20 +70,15 @@ def initialize @timeout_thread_mutex = Mutex.new end - if defined?(Ractor.store_if_absent) && - defined?(Ractor.shareable?) && Ractor.shareable?(GET_TIME) - - # Ractor support if - # 1. Ractor.store_if_absent is available - # 2. Method object can be shareable (4.0~) - - Ractor.store_if_absent :timeout_gem_state do - State.new - end - - def self.instance - Ractor[:timeout_gem_state] - end + if defined?(Ractor.store_if_absent) && defined?(Ractor.shareable?) && Ractor.shareable?(GET_TIME) + # Ractor support if + # 1. Ractor.store_if_absent is available + # 2. Method object can be shareable (4.0~) + def self.instance + Ractor.store_if_absent :timeout_gem_state do + State.new + end + end else GLOBAL_STATE = State.new From b5d7d777a4d2968c979e74c0f4befa7d3ecdf3dc Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Fri, 5 Dec 2025 19:25:22 +0100 Subject: [PATCH 5/5] Simplify logic to make GET_TIME shareable --- lib/timeout.rb | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/timeout.rb b/lib/timeout.rb index eab1a1b..36cd0f9 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -46,16 +46,11 @@ def self.handle_timeout(message) # :nodoc: # :stopdoc: # We keep a private reference so that time mocking libraries won't break Timeout. - GET_TIME = - if defined?(Ractor.make_shareable) - begin - Ractor.make_shareable(Process.method(:clock_gettime)) - rescue # failed on Ruby 3.4 - Process.method(:clock_gettime) - end - else - Process.method(:clock_gettime) - end + GET_TIME = Process.method(:clock_gettime) + if defined?(Ractor.make_shareable) + # Ractor.make_shareable(Method) only works on Ruby 4+ + Ractor.make_shareable(GET_TIME) rescue nil + end private_constant :GET_TIME class State