Skip to content

Commit 2bafc45

Browse files
committed
Reimplement Timeout.timeout with a single thread and a Queue
1 parent 9b18d10 commit 2bafc45

File tree

2 files changed

+101
-37
lines changed

2 files changed

+101
-37
lines changed

lib/timeout.rb

Lines changed: 88 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# frozen_string_literal: false
1+
# frozen_string_literal: true
22
# Timeout long-running blocks
33
#
44
# == Synopsis
@@ -23,7 +23,7 @@
2323
# Copyright:: (C) 2000 Information-technology Promotion Agency, Japan
2424

2525
module Timeout
26-
VERSION = "0.2.0".freeze
26+
VERSION = "0.2.0"
2727

2828
# Raised by Timeout.timeout when the block times out.
2929
class Error < RuntimeError
@@ -50,9 +50,79 @@ def exception(*)
5050
end
5151

5252
# :stopdoc:
53-
THIS_FILE = /\A#{Regexp.quote(__FILE__)}:/o
54-
CALLER_OFFSET = ((c = caller[0]) && THIS_FILE =~ c) ? 1 : 0
55-
private_constant :THIS_FILE, :CALLER_OFFSET
53+
CONDVAR = ConditionVariable.new
54+
QUEUE = Queue.new
55+
QUEUE_MUTEX = Mutex.new
56+
TIMEOUT_THREAD_MUTEX = Mutex.new
57+
@timeout_thread = nil
58+
private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX
59+
60+
class Request
61+
attr_reader :deadline
62+
63+
def initialize(thread, timeout, exception_class, message)
64+
@thread = thread
65+
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
66+
@exception_class = exception_class
67+
@message = message
68+
69+
@mutex = Mutex.new
70+
@done = false
71+
end
72+
73+
def done?
74+
@done
75+
end
76+
77+
def expired?(now)
78+
now >= @deadline and !done?
79+
end
80+
81+
def interrupt
82+
@mutex.synchronize do
83+
unless @done
84+
@thread.raise @exception_class, @message
85+
@done = true
86+
end
87+
end
88+
end
89+
90+
def finished
91+
@mutex.synchronize do
92+
@done = true
93+
end
94+
end
95+
end
96+
private_constant :Request
97+
98+
def self.ensure_timeout_thread_created
99+
unless @timeout_thread
100+
TIMEOUT_THREAD_MUTEX.synchronize do
101+
@timeout_thread ||= Thread.new do
102+
requests = []
103+
while true
104+
until QUEUE.empty? and !requests.empty? # wait to have at least one request
105+
req = QUEUE.pop
106+
requests << req unless req.done?
107+
end
108+
closest_deadline = requests.min_by(&:deadline).deadline
109+
110+
now = 0.0
111+
QUEUE_MUTEX.synchronize do
112+
while (now = Process.clock_gettime(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty?
113+
CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now)
114+
end
115+
end
116+
117+
requests.each do |req|
118+
req.interrupt if req.expired?(now)
119+
end
120+
requests.reject!(&:done?)
121+
end
122+
end
123+
end
124+
end
125+
end
56126
# :startdoc:
57127

58128
# Perform an operation in a block, raising an error if it takes longer than
@@ -83,51 +153,32 @@ def exception(*)
83153
def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
84154
return yield(sec) if sec == nil or sec.zero?
85155

86-
message ||= "execution expired".freeze
156+
message ||= "execution expired"
87157

88158
if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after)
89159
return scheduler.timeout_after(sec, klass || Error, message, &block)
90160
end
91161

92-
from = "from #{caller_locations(1, 1)[0]}" if $DEBUG
93-
e = Error
94-
bl = proc do |exception|
162+
Timeout.ensure_timeout_thread_created
163+
perform = Proc.new do |exc|
164+
request = Request.new(Thread.current, sec, exc, message)
165+
QUEUE_MUTEX.synchronize do
166+
QUEUE << request
167+
CONDVAR.signal
168+
end
95169
begin
96-
x = Thread.current
97-
y = Thread.start {
98-
Thread.current.name = from
99-
begin
100-
sleep sec
101-
rescue => e
102-
x.raise e
103-
else
104-
x.raise exception, message
105-
end
106-
}
107170
return yield(sec)
108171
ensure
109-
if y
110-
y.kill
111-
y.join # make sure y is dead.
112-
end
172+
request.finished
113173
end
114174
end
175+
115176
if klass
116-
begin
117-
bl.call(klass)
118-
rescue klass => e
119-
message = e.message
120-
bt = e.backtrace
121-
end
177+
perform.call(klass)
122178
else
123-
bt = Error.catch(message, &bl)
179+
backtrace = Error.catch(&perform)
180+
raise Error, message, backtrace
124181
end
125-
level = -caller(CALLER_OFFSET).size-2
126-
while THIS_FILE =~ bt[level]
127-
bt.delete_at(level)
128-
end
129-
raise(e, message, bt)
130182
end
131-
132183
module_function :timeout
133184
end

test/test_timeout.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ def test_non_timing_out_code_is_successful
1010
end
1111
end
1212

13+
def test_included
14+
c = Class.new do
15+
include Timeout
16+
def test
17+
timeout(1) { :ok }
18+
end
19+
end
20+
assert_nothing_raised do
21+
assert_equal :ok, c.new.test
22+
end
23+
end
24+
1325
def test_yield_param
1426
assert_equal [5, :ok], Timeout.timeout(5){|s| [s, :ok] }
1527
end
@@ -43,6 +55,7 @@ def test_skip_rescue
4355
begin
4456
sleep 3
4557
rescue Exception => e
58+
flunk "should not see any exception but saw #{e.inspect}"
4659
end
4760
end
4861
end

0 commit comments

Comments
 (0)