summaryrefslogtreecommitdiff
path: root/lib/timeout.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/timeout.rb')
-rw-r--r--lib/timeout.rb171
1 files changed, 119 insertions, 52 deletions
diff --git a/lib/timeout.rb b/lib/timeout.rb
index e7b11c0a86..c67a748856 100644
--- a/lib/timeout.rb
+++ b/lib/timeout.rb
@@ -1,4 +1,4 @@
-# frozen_string_literal: false
+# frozen_string_literal: true
# Timeout long-running blocks
#
# == Synopsis
@@ -23,36 +23,123 @@
# Copyright:: (C) 2000 Information-technology Promotion Agency, Japan
module Timeout
- VERSION = "0.1.1"
+ # The version
+ VERSION = "0.4.1"
+
+ # Internal error raised to when a timeout is triggered.
+ class ExitException < Exception
+ def exception(*) # :nodoc:
+ self
+ end
+ end
# Raised by Timeout.timeout when the block times out.
class Error < RuntimeError
- attr_reader :thread
+ def self.handle_timeout(message) # :nodoc:
+ exc = ExitException.new(message)
+
+ begin
+ yield exc
+ rescue ExitException => e
+ raise new(message) if exc.equal?(e)
+ raise
+ end
+ end
+ 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
+
+ class Request
+ attr_reader :deadline
+
+ def initialize(thread, timeout, exception_class, message)
+ @thread = thread
+ @deadline = GET_TIME.call(Process::CLOCK_MONOTONIC) + timeout
+ @exception_class = exception_class
+ @message = message
- def self.catch(*args)
- exc = new(*args)
- exc.instance_variable_set(:@thread, Thread.current)
- exc.instance_variable_set(:@catch_value, exc)
- ::Kernel.catch(exc) {yield exc}
+ @mutex = Mutex.new
+ @done = false # protected by @mutex
end
- def exception(*)
- # TODO: use Fiber.current to see if self can be thrown
- if self.thread == Thread.current
- bt = caller
- begin
- throw(@catch_value, bt)
- rescue UncaughtThrowError
+ def done?
+ @mutex.synchronize do
+ @done
+ end
+ end
+
+ def expired?(now)
+ now >= @deadline
+ end
+
+ def interrupt
+ @mutex.synchronize do
+ unless @done
+ @thread.raise @exception_class, @message
+ @done = true
end
end
- super
+ end
+
+ def finished
+ @mutex.synchronize 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
+ 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
- # :stopdoc:
- THIS_FILE = /\A#{Regexp.quote(__FILE__)}:/o
- CALLER_OFFSET = ((c = caller[0]) && THIS_FILE =~ c) ? 1 : 0
- private_constant :THIS_FILE, :CALLER_OFFSET
# :startdoc:
# Perform an operation in a block, raising an error if it takes longer than
@@ -83,51 +170,31 @@ module Timeout
def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
return yield(sec) if sec == nil or sec.zero?
- message ||= "execution expired".freeze
+ message ||= "execution expired"
if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after)
return scheduler.timeout_after(sec, klass || Error, message, &block)
end
- from = "from #{caller_locations(1, 1)[0]}" if $DEBUG
- e = Error
- bl = proc do |exception|
+ Timeout.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
begin
- x = Thread.current
- y = Thread.start {
- Thread.current.name = from
- begin
- sleep sec
- rescue => e
- x.raise e
- else
- x.raise exception, message
- end
- }
return yield(sec)
ensure
- if y
- y.kill
- y.join # make sure y is dead.
- end
+ request.finished
end
end
+
if klass
- begin
- bl.call(klass)
- rescue klass => e
- message = e.message
- bt = e.backtrace
- end
+ perform.call(klass)
else
- bt = Error.catch(message, &bl)
+ Error.handle_timeout(message, &perform)
end
- level = -caller(CALLER_OFFSET).size-2
- while THIS_FILE =~ bt[level]
- bt.delete_at(level)
- end
- raise(e, message, bt)
end
-
module_function :timeout
end