summaryrefslogtreecommitdiff
path: root/lib/timeout.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/timeout.rb')
-rw-r--r--lib/timeout.rb245
1 files changed, 179 insertions, 66 deletions
diff --git a/lib/timeout.rb b/lib/timeout.rb
index 4fd1fa46da..e293e3be7c 100644
--- a/lib/timeout.rb
+++ b/lib/timeout.rb
@@ -20,9 +20,9 @@
module Timeout
# The version
- VERSION = "0.4.3"
+ VERSION = "0.6.0"
- # Internal error raised to when a timeout is triggered.
+ # Internal exception raised to when a timeout is triggered.
class ExitException < Exception
def exception(*) # :nodoc:
self
@@ -44,12 +44,101 @@ module Timeout
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 = 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
+ 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.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
+
+ def self.instance
+ GLOBAL_STATE
+ end
+ end
+
+ def create_timeout_thread
+ # Threads unexpectedly inherit the interrupt mask: https://github.com/ruby/timeout/issues/41
+ # So reset the interrupt mask to the default one for the timeout thread
+ Thread.handle_interrupt(Object => :immediate) do
+ 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
+ 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?
+
+ Sync.synchronize @timeout_thread_mutex do
+ unless @timeout_thread&.alive?
+ @timeout_thread = create_timeout_thread
+ end
+ end
+ end
+ end
+
+ def add_request(request)
+ Sync.synchronize @queue_mutex do
+ @queue << request
+ @condvar.signal
+ end
+ end
+ end
+ private_constant :State
class Request
attr_reader :deadline
@@ -64,6 +153,7 @@ module Timeout
@done = false # protected by @mutex
end
+ # Only called by the timeout thread, so does not need Sync.synchronize
def done?
@mutex.synchronize do
@done
@@ -74,6 +164,7 @@ module Timeout
now >= @deadline
end
+ # Only called by the timeout thread, so does not need Sync.synchronize
def interrupt
@mutex.synchronize do
unless @done
@@ -84,61 +175,36 @@ module Timeout
end
def finished
- @mutex.synchronize do
+ Sync.synchronize @mutex do
@done = true
end
end
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?
- TIMEOUT_THREAD_MUTEX.synchronize do
- unless @timeout_thread and @timeout_thread.alive?
- @timeout_thread = create_timeout_thread
- end
+ module Sync
+ # Calls mutex.synchronize(&block) but if that fails on CRuby due to being in a trap handler,
+ # run mutex.synchronize(&block) in a separate Thread instead.
+ def self.synchronize(mutex, &block)
+ begin
+ mutex.synchronize(&block)
+ rescue ThreadError => e
+ raise e unless e.message == "can't be called from trap context"
+ # Workaround CRuby issue https://bugs.ruby-lang.org/issues/19473
+ # which raises on Mutex#synchronize in trap handler.
+ # It's expensive to create a Thread just for this,
+ # but better than failing.
+ Thread.new {
+ mutex.synchronize(&block)
+ }.join
end
end
end
-
- # We keep a private reference so that time mocking libraries won't break
- # Timeout.
- GET_TIME = Process.method(:clock_gettime)
- private_constant :GET_TIME
+ private_constant :Sync
# :startdoc:
- # Perform an operation in a block, raising an error if it takes longer than
+ # Perform an operation in a block, raising an exception if it takes longer than
# +sec+ seconds to complete.
#
# +sec+:: Number of seconds to wait for the block to terminate. Any non-negative number
@@ -146,25 +212,70 @@ module Timeout
# value of 0 or +nil+ will execute the block without any timeout.
# Any negative number will raise an ArgumentError.
# +klass+:: Exception Class to raise if the block fails to terminate
- # in +sec+ seconds. Omitting will use the default, Timeout::Error
+ # in +sec+ seconds. Omitting will use the default, Timeout::Error.
# +message+:: Error message to raise with Exception Class.
- # Omitting will use the default, "execution expired"
+ # Omitting will use the default, <tt>"execution expired"</tt>.
#
# Returns the result of the block *if* the block completed before
- # +sec+ seconds, otherwise throws an exception, based on the value of +klass+.
+ # +sec+ seconds, otherwise raises an exception, based on the value of +klass+.
#
- # The exception thrown to terminate the given block cannot be rescued inside
- # the block unless +klass+ is given explicitly. However, the block can use
- # ensure to prevent the handling of the exception. For that reason, this
- # method cannot be relied on to enforce timeouts for untrusted blocks.
+ # The exception raised to terminate the given block is the given +klass+, or
+ # Timeout::ExitException if +klass+ is not given. The reason for that behavior
+ # is that Timeout::Error inherits from RuntimeError and might be caught unexpectedly by +rescue+.
+ # Timeout::ExitException inherits from Exception so it will only be rescued by <tt>rescue Exception</tt>.
+ # Note that the Timeout::ExitException is translated to a Timeout::Error once it reaches the Timeout.timeout call,
+ # so outside that call it will be a Timeout::Error.
+ #
+ # In general, be aware that the code block may rescue the exception, and in such a case not respect the timeout.
+ # Also, the block can use +ensure+ to prevent the handling of the exception.
+ # For those reasons, this method cannot be relied on to enforce timeouts for untrusted blocks.
#
# If a scheduler is defined, it will be used to handle the timeout by invoking
- # Scheduler#timeout_after.
+ # Fiber::Scheduler#timeout_after.
#
# Note that this is both a method of module Timeout, so you can <tt>include
# Timeout</tt> into your classes so they have a #timeout method, as well as
# a module method, so you can call it directly as Timeout.timeout().
- def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
+ #
+ # ==== Ensuring the exception does not fire inside ensure blocks
+ #
+ # When using Timeout.timeout, it can be desirable to ensure the timeout exception does not fire inside an +ensure+ block.
+ # The simplest and best way to do so is to put the Timeout.timeout call inside the body of the +begin+/+ensure+/+end+:
+ #
+ # begin
+ # Timeout.timeout(sec) { some_long_operation }
+ # ensure
+ # cleanup # safe, cannot be interrupted by timeout
+ # end
+ #
+ # If that is not feasible, e.g. if there are +ensure+ blocks inside +some_long_operation+,
+ # they need to not be interrupted by timeout, and it's not possible to move these ensure blocks outside,
+ # one can use Thread.handle_interrupt to delay the timeout exception like so:
+ #
+ # Thread.handle_interrupt(Timeout::Error => :never) {
+ # Timeout.timeout(sec, Timeout::Error) do
+ # setup # timeout cannot happen here, no matter how long it takes
+ # Thread.handle_interrupt(Timeout::Error => :immediate) {
+ # some_long_operation # timeout can happen here
+ # }
+ # ensure
+ # cleanup # timeout cannot happen here, no matter how long it takes
+ # end
+ # }
+ #
+ # An important thing to note is the need to pass an exception +klass+ to Timeout.timeout,
+ # otherwise it does not work. Specifically, using <tt>Thread.handle_interrupt(Timeout::ExitException => ...)</tt>
+ # is unsupported and causes subtle errors like raising the wrong exception outside the block, do not use that.
+ #
+ # Note that Thread.handle_interrupt is somewhat dangerous because if setup or cleanup hangs
+ # then the current thread will hang too and the timeout will never fire.
+ # Also note the block might run for longer than +sec+ seconds:
+ # e.g. +some_long_operation+ executes for +sec+ seconds + whatever time cleanup takes.
+ #
+ # If you want the timeout to only happen on blocking operations, one can use +:on_blocking+
+ # instead of +:immediate+. However, that means if the block uses no blocking operations after +sec+ seconds,
+ # the block will not be interrupted.
+ def self.timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
return yield(sec) if sec == nil or sec.zero?
raise ArgumentError, "Timeout sec must be a non-negative number" if 0 > sec
@@ -174,13 +285,12 @@ module Timeout
return scheduler.timeout_after(sec, klass || Error, message, &block)
end
- Timeout.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
- end
+ state.add_request(request)
begin
return yield(sec)
ensure
@@ -194,5 +304,8 @@ module Timeout
Error.handle_timeout(message, &perform)
end
end
- module_function :timeout
+
+ private def timeout(*args, &block)
+ Timeout.timeout(*args, &block)
+ end
end