diff options
Diffstat (limited to 'lib/timeout.rb')
-rw-r--r-- | lib/timeout.rb | 171 |
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 |