Skip to content

Commit f16545a

Browse files
jeremyevansnobu
andauthored
Raise exception instead of throw/catch for timeouts (#30)
throw/catch is used for non-local control flow, not for exceptional situations. For exceptional situations, raise should be used instead. A timeout is an exceptional situation, so it should use raise, not throw/catch. Timeout's implementation that uses throw/catch internally causes serious problems. Consider the following code: ```ruby def handle_exceptions yield rescue Exception => exc handle_error # e.g. ROLLBACK for databases raise ensure handle_exit unless exc # e.g. COMMIT for databases end Timeout.timeout(1) do handle_exceptions do do_something end end ``` This kind of design ensures that all exceptions are handled as errors, and ensures that all exits (normal exit, early return, throw/catch) are not handled as errors. With Timeout's throw/catch implementation, this type of code does not work, since a timeout triggers the normal exit path. See rails/rails#29333 for an example of the damage Timeout's design has caused the Rails ecosystem. This switches Timeout.timeout to use raise/rescue internally. It adds a Timeout::ExitException subclass of exception for the internal raise/rescue, which Timeout.timeout will convert to Timeout::Error for backwards compatibility. Timeout::Error remains a subclass of RuntimeError. This is how timeout used to work in Ruby 2.0. It was changed in Ruby 2.1, after discussion in [Bug #8730] (commit 238c003 in the timeout repository). I think the change from using raise/rescue to using throw/catch has caused significant harm to the Ruby ecosystem at large, and reverting it is the most sensible choice. From the translation of [Bug #8730], it appears the issue was that someone could rescue Exception and not reraise the exception, causing timeout errors to be swallowed. However, such code is broken anyway. Using throw/catch causes far worse problems, because then it becomes impossible to differentiate between normal control flow and exceptional control flow. Also related to this is [Bug #11344], which changed how Thread.handle_interrupt interacted with Timeout. Co-authored-by: Nobuyoshi Nakada <nobu@ruby-lang.org>
1 parent cae26ed commit f16545a

File tree

2 files changed

+48
-26
lines changed

2 files changed

+48
-26
lines changed

lib/timeout.rb

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,27 +25,24 @@
2525
module Timeout
2626
VERSION = "0.3.2"
2727

28+
# Internal error raised to when a timeout is triggered.
29+
class ExitException < Exception
30+
def exception(*)
31+
self
32+
end
33+
end
34+
2835
# Raised by Timeout.timeout when the block times out.
2936
class Error < RuntimeError
30-
attr_reader :thread
37+
def self.handle_timeout(message)
38+
exc = ExitException.new(message)
3139

32-
def self.catch(*args)
33-
exc = new(*args)
34-
exc.instance_variable_set(:@thread, Thread.current)
35-
exc.instance_variable_set(:@catch_value, exc)
36-
::Kernel.catch(exc) {yield exc}
37-
end
38-
39-
def exception(*)
40-
# TODO: use Fiber.current to see if self can be thrown
41-
if self.thread == Thread.current
42-
bt = caller
43-
begin
44-
throw(@catch_value, bt)
45-
rescue UncaughtThrowError
46-
end
40+
begin
41+
yield exc
42+
rescue ExitException => e
43+
raise new(message) if exc.equal?(e)
44+
raise
4745
end
48-
super
4946
end
5047
end
5148

@@ -195,8 +192,7 @@ def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
195192
if klass
196193
perform.call(klass)
197194
else
198-
backtrace = Error.catch(&perform)
199-
raise Error, message, backtrace
195+
Error.handle_timeout(message, &perform)
200196
end
201197
end
202198
module_function :timeout

test/test_timeout.rb

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,51 @@ def test_timeout
4141
end
4242
end
4343

44+
def test_nested_timeout
45+
a = nil
46+
assert_raise(Timeout::Error) do
47+
Timeout.timeout(0.1) {
48+
Timeout.timeout(1) {
49+
nil while true
50+
}
51+
a = 1
52+
}
53+
end
54+
assert_nil a
55+
end
56+
4457
def test_cannot_convert_into_time_interval
4558
bug3168 = '[ruby-dev:41010]'
4659
def (n = Object.new).zero?; false; end
4760
assert_raise(TypeError, bug3168) {Timeout.timeout(n) { sleep 0.1 }}
4861
end
4962

50-
def test_skip_rescue
51-
bug8730 = '[Bug #8730]'
63+
def test_skip_rescue_standarderror
5264
e = nil
53-
assert_raise_with_message(Timeout::Error, /execution expired/, bug8730) do
65+
assert_raise_with_message(Timeout::Error, /execution expired/) do
5466
Timeout.timeout 0.01 do
5567
begin
5668
sleep 3
57-
rescue Exception => e
69+
rescue => e
5870
flunk "should not see any exception but saw #{e.inspect}"
5971
end
6072
end
6173
end
62-
assert_nil(e, bug8730)
74+
end
75+
76+
def test_raises_exception_internally
77+
e = nil
78+
assert_raise_with_message(Timeout::Error, /execution expired/) do
79+
Timeout.timeout 0.01 do
80+
begin
81+
sleep 3
82+
rescue Exception => exc
83+
e = exc
84+
raise
85+
end
86+
end
87+
end
88+
assert_equal Timeout::ExitException, e.class
6389
end
6490

6591
def test_rescue_exit
@@ -127,11 +153,11 @@ def test_handle_interrupt
127153
bug11344 = '[ruby-dev:49179] [Bug #11344]'
128154
ok = false
129155
assert_raise(Timeout::Error) {
130-
Thread.handle_interrupt(Timeout::Error => :never) {
156+
Thread.handle_interrupt(Timeout::ExitException => :never) {
131157
Timeout.timeout(0.01) {
132158
sleep 0.2
133159
ok = true
134-
Thread.handle_interrupt(Timeout::Error => :on_blocking) {
160+
Thread.handle_interrupt(Timeout::ExitException => :on_blocking) {
135161
sleep 0.2
136162
}
137163
}

0 commit comments

Comments
 (0)