Skip to content

Commit a52720e

Browse files
committed
Reset the interrupt mask when creating the Timeout thread
* Add tests related to Thread.handle_interrupt * Fixes #41
1 parent 2af214f commit a52720e

File tree

2 files changed

+140
-23
lines changed

2 files changed

+140
-23
lines changed

lib/timeout.rb

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -83,36 +83,40 @@ def self.instance
8383
end
8484

8585
def create_timeout_thread
86-
watcher = Thread.new do
87-
requests = []
88-
while true
89-
until @queue.empty? and !requests.empty? # wait to have at least one request
90-
req = @queue.pop
91-
requests << req unless req.done?
92-
end
93-
closest_deadline = requests.min_by(&:deadline).deadline
86+
# Threads unexpectedly inherit the interrupt mask: https://github.com/ruby/timeout/issues/41
87+
# So reset the interrupt mask to the default one for the timeout thread
88+
Thread.handle_interrupt(Object => :immediate) do
89+
watcher = Thread.new do
90+
requests = []
91+
while true
92+
until @queue.empty? and !requests.empty? # wait to have at least one request
93+
req = @queue.pop
94+
requests << req unless req.done?
95+
end
96+
closest_deadline = requests.min_by(&:deadline).deadline
9497

95-
now = 0.0
96-
@queue_mutex.synchronize do
97-
while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and @queue.empty?
98-
@condvar.wait(@queue_mutex, closest_deadline - now)
98+
now = 0.0
99+
@queue_mutex.synchronize do
100+
while (now = GET_TIME.call(Process::CLOCK_MONOTONIC)) < closest_deadline and @queue.empty?
101+
@condvar.wait(@queue_mutex, closest_deadline - now)
102+
end
99103
end
100-
end
101104

102-
requests.each do |req|
103-
req.interrupt if req.expired?(now)
105+
requests.each do |req|
106+
req.interrupt if req.expired?(now)
107+
end
108+
requests.reject!(&:done?)
104109
end
105-
requests.reject!(&:done?)
106110
end
107-
end
108111

109-
if !watcher.group.enclosed? && (!defined?(Ractor.main?) || Ractor.main?)
110-
ThreadGroup::Default.add(watcher)
111-
end
112+
if !watcher.group.enclosed? && (!defined?(Ractor.main?) || Ractor.main?)
113+
ThreadGroup::Default.add(watcher)
114+
end
112115

113-
watcher.name = "Timeout stdlib thread"
114-
watcher.thread_variable_set(:"\0__detached_thread__", true)
115-
watcher
116+
watcher.name = "Timeout stdlib thread"
117+
watcher.thread_variable_set(:"\0__detached_thread__", true)
118+
watcher
119+
end
116120
end
117121

118122
def ensure_timeout_thread_created

test/test_timeout.rb

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
class TestTimeout < Test::Unit::TestCase
66

7+
private def kill_timeout_thread
8+
thread = Timeout.const_get(:State).instance.instance_variable_get(:@timeout_thread)
9+
thread.kill
10+
thread.join
11+
end
12+
713
def test_public_methods
814
assert_equal [:timeout], Timeout.private_instance_methods(false)
915
assert_equal [], Timeout.public_instance_methods(false)
@@ -221,6 +227,24 @@ def o.each
221227
end
222228
end
223229

230+
def test_handle_interrupt_with_exception_class
231+
bug11344 = '[ruby-dev:49179] [Bug #11344]'
232+
ok = false
233+
assert_raise(Timeout::Error) {
234+
Thread.handle_interrupt(Timeout::Error => :never) {
235+
Timeout.timeout(0.01, Timeout::Error) {
236+
sleep 0.2
237+
ok = true
238+
Thread.handle_interrupt(Timeout::Error => :on_blocking) {
239+
sleep 0.2
240+
raise "unreachable"
241+
}
242+
}
243+
}
244+
}
245+
assert(ok, bug11344)
246+
end
247+
224248
def test_handle_interrupt
225249
bug11344 = '[ruby-dev:49179] [Bug #11344]'
226250
ok = false
@@ -231,13 +255,102 @@ def test_handle_interrupt
231255
ok = true
232256
Thread.handle_interrupt(Timeout::ExitException => :on_blocking) {
233257
sleep 0.2
258+
raise "unreachable"
234259
}
235260
}
236261
}
237262
}
238263
assert(ok, bug11344)
239264
end
240265

266+
def test_handle_interrupt_with_interrupt_mask_inheritance
267+
issue = 'https://github.com/ruby/timeout/issues/41'
268+
269+
[
270+
-> {}, # not blocking so no opportunity to interrupt
271+
-> { sleep 5 }
272+
].each_with_index do |body, idx|
273+
# We need to create a new Timeout thread
274+
kill_timeout_thread
275+
276+
# Create the timeout thread under a handle_interrupt(:never)
277+
# due to the interrupt mask being inherited
278+
Thread.handle_interrupt(Object => :never) {
279+
assert_equal :ok, Timeout.timeout(1) { :ok }
280+
}
281+
282+
# Ensure a simple timeout works and the interrupt mask was not inherited
283+
assert_raise(Timeout::Error) {
284+
Timeout.timeout(0.001) { sleep 1 }
285+
}
286+
287+
r = []
288+
# This raises Timeout::ExitException and not Timeout::Error for the non-blocking body
289+
# because of the handle_interrupt(:never) which delays raising Timeout::ExitException
290+
# on the main thread until getting outside of that handle_interrupt(:never) call.
291+
# For this reason we document handle_interrupt(Timeout::ExitException) should not be used.
292+
exc = idx == 0 ? Timeout::ExitException : Timeout::Error
293+
assert_raise(exc) {
294+
Thread.handle_interrupt(Timeout::ExitException => :never) {
295+
Timeout.timeout(0.1) do
296+
sleep 0.2
297+
r << :sleep_before_done
298+
Thread.handle_interrupt(Timeout::ExitException => :on_blocking) {
299+
r << :body
300+
body.call
301+
}
302+
ensure
303+
sleep 0.2
304+
r << :ensure_sleep_done
305+
end
306+
}
307+
}
308+
assert_equal([:sleep_before_done, :body, :ensure_sleep_done], r, issue)
309+
end
310+
end
311+
312+
# Same as above but with an exception class
313+
def test_handle_interrupt_with_interrupt_mask_inheritance_with_exception_class
314+
issue = 'https://github.com/ruby/timeout/issues/41'
315+
316+
[
317+
-> {}, # not blocking so no opportunity to interrupt
318+
-> { sleep 5 }
319+
].each do |body|
320+
# We need to create a new Timeout thread
321+
kill_timeout_thread
322+
323+
# Create the timeout thread under a handle_interrupt(:never)
324+
# due to the interrupt mask being inherited
325+
Thread.handle_interrupt(Object => :never) {
326+
assert_equal :ok, Timeout.timeout(1) { :ok }
327+
}
328+
329+
# Ensure a simple timeout works and the interrupt mask was not inherited
330+
assert_raise(Timeout::Error) {
331+
Timeout.timeout(0.001) { sleep 1 }
332+
}
333+
334+
r = []
335+
assert_raise(Timeout::Error) {
336+
Thread.handle_interrupt(Timeout::Error => :never) {
337+
Timeout.timeout(0.1, Timeout::Error) do
338+
sleep 0.2
339+
r << :sleep_before_done
340+
Thread.handle_interrupt(Timeout::Error => :on_blocking) {
341+
r << :body
342+
body.call
343+
}
344+
ensure
345+
sleep 0.2
346+
r << :ensure_sleep_done
347+
end
348+
}
349+
}
350+
assert_equal([:sleep_before_done, :body, :ensure_sleep_done], r, issue)
351+
end
352+
end
353+
241354
def test_fork
242355
omit 'fork not supported' unless Process.respond_to?(:fork)
243356
r, w = IO.pipe

0 commit comments

Comments
 (0)