Skip to content

Commit 701ac78

Browse files
authored
Merge pull request #15 from eregon/single-timeout-thread-pure-ruby
Reimplement Timeout.timeout with a single thread and a Queue
2 parents 9b18d10 + 5f43254 commit 701ac78

File tree

3 files changed

+133
-39
lines changed

3 files changed

+133
-39
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
name: ubuntu
1+
name: test
22

33
on: [push, pull_request]
44

55
jobs:
66
build:
77
name: build (${{ matrix.ruby }} / ${{ matrix.os }})
88
strategy:
9+
fail-fast: false
910
matrix:
10-
ruby: [ '3.0', 2.7, 2.6, 2.5, 2.4, head ]
11+
ruby: [ '3.0', 2.7, 2.6, 2.5, 2.4, head, jruby, truffleruby-head ]
1112
os: [ ubuntu-latest, macos-latest ]
1213
runs-on: ${{ matrix.os }}
1314
steps:

lib/timeout.rb

Lines changed: 97 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,88 @@ 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 # protected by @mutex
71+
end
72+
73+
def done?
74+
@mutex.synchronize do
75+
@done
76+
end
77+
end
78+
79+
def expired?(now)
80+
now >= @deadline
81+
end
82+
83+
def interrupt
84+
@mutex.synchronize do
85+
unless @done
86+
@thread.raise @exception_class, @message
87+
@done = true
88+
end
89+
end
90+
end
91+
92+
def finished
93+
@mutex.synchronize do
94+
@done = true
95+
end
96+
end
97+
end
98+
private_constant :Request
99+
100+
def self.create_timeout_thread
101+
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+
private_class_method :create_timeout_thread
125+
126+
def self.ensure_timeout_thread_created
127+
unless @timeout_thread and @timeout_thread.alive?
128+
TIMEOUT_THREAD_MUTEX.synchronize do
129+
unless @timeout_thread and @timeout_thread.alive?
130+
@timeout_thread = create_timeout_thread
131+
end
132+
end
133+
end
134+
end
56135
# :startdoc:
57136

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

86-
message ||= "execution expired".freeze
165+
message ||= "execution expired"
87166

88167
if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after)
89168
return scheduler.timeout_after(sec, klass || Error, message, &block)
90169
end
91170

92-
from = "from #{caller_locations(1, 1)[0]}" if $DEBUG
93-
e = Error
94-
bl = proc do |exception|
171+
Timeout.ensure_timeout_thread_created
172+
perform = Proc.new do |exc|
173+
request = Request.new(Thread.current, sec, exc, message)
174+
QUEUE_MUTEX.synchronize do
175+
QUEUE << request
176+
CONDVAR.signal
177+
end
95178
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-
}
107179
return yield(sec)
108180
ensure
109-
if y
110-
y.kill
111-
y.join # make sure y is dead.
112-
end
181+
request.finished
113182
end
114183
end
184+
115185
if klass
116-
begin
117-
bl.call(klass)
118-
rescue klass => e
119-
message = e.message
120-
bt = e.backtrace
121-
end
186+
perform.call(klass)
122187
else
123-
bt = Error.catch(message, &bl)
188+
backtrace = Error.catch(&perform)
189+
raise Error, message, backtrace
124190
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)
130191
end
131-
132192
module_function :timeout
133193
end

test/test_timeout.rb

Lines changed: 33 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
@@ -126,4 +139,24 @@ def test_handle_interrupt
126139
}
127140
assert(ok, bug11344)
128141
end
142+
143+
def test_fork
144+
omit 'fork not supported' unless Process.respond_to?(:fork)
145+
r, w = IO.pipe
146+
pid = fork do
147+
r.close
148+
begin
149+
r = Timeout.timeout(0.01) { sleep 5 }
150+
w.write r.inspect
151+
rescue Timeout::Error
152+
w.write 'timeout'
153+
ensure
154+
w.close
155+
end
156+
end
157+
w.close
158+
Process.wait pid
159+
assert_equal 'timeout', r.read
160+
r.close
161+
end
129162
end

0 commit comments

Comments
 (0)