[ruby/timeout] Raise exception instead of throw/catch for timeouts
(https://github.com/ruby/timeout/pull/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 https://github.com/rails/rails/pull/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 https://github.com/ruby/timeout/commit/238c003c921e 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. https://github.com/ruby/timeout/commit/f16545abe6 Co-authored-by: Nobuyoshi Nakada <nobu@ruby-lang.org>
This commit is contained in:
parent
b934976024
commit
e8c9385123
@ -25,27 +25,24 @@
|
|||||||
module Timeout
|
module Timeout
|
||||||
VERSION = "0.3.2"
|
VERSION = "0.3.2"
|
||||||
|
|
||||||
|
# Internal error raised to when a timeout is triggered.
|
||||||
|
class ExitException < Exception
|
||||||
|
def exception(*)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Raised by Timeout.timeout when the block times out.
|
# Raised by Timeout.timeout when the block times out.
|
||||||
class Error < RuntimeError
|
class Error < RuntimeError
|
||||||
attr_reader :thread
|
def self.handle_timeout(message)
|
||||||
|
exc = ExitException.new(message)
|
||||||
|
|
||||||
def self.catch(*args)
|
begin
|
||||||
exc = new(*args)
|
yield exc
|
||||||
exc.instance_variable_set(:@thread, Thread.current)
|
rescue ExitException => e
|
||||||
exc.instance_variable_set(:@catch_value, exc)
|
raise new(message) if exc.equal?(e)
|
||||||
::Kernel.catch(exc) {yield exc}
|
raise
|
||||||
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
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
super
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -195,8 +192,7 @@ module Timeout
|
|||||||
if klass
|
if klass
|
||||||
perform.call(klass)
|
perform.call(klass)
|
||||||
else
|
else
|
||||||
backtrace = Error.catch(&perform)
|
Error.handle_timeout(message, &perform)
|
||||||
raise Error, message, backtrace
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
module_function :timeout
|
module_function :timeout
|
||||||
|
@ -41,25 +41,51 @@ class TestTimeout < Test::Unit::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_nested_timeout
|
||||||
|
a = nil
|
||||||
|
assert_raise(Timeout::Error) do
|
||||||
|
Timeout.timeout(0.1) {
|
||||||
|
Timeout.timeout(1) {
|
||||||
|
nil while true
|
||||||
|
}
|
||||||
|
a = 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
assert_nil a
|
||||||
|
end
|
||||||
|
|
||||||
def test_cannot_convert_into_time_interval
|
def test_cannot_convert_into_time_interval
|
||||||
bug3168 = '[ruby-dev:41010]'
|
bug3168 = '[ruby-dev:41010]'
|
||||||
def (n = Object.new).zero?; false; end
|
def (n = Object.new).zero?; false; end
|
||||||
assert_raise(TypeError, bug3168) {Timeout.timeout(n) { sleep 0.1 }}
|
assert_raise(TypeError, bug3168) {Timeout.timeout(n) { sleep 0.1 }}
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_skip_rescue
|
def test_skip_rescue_standarderror
|
||||||
bug8730 = '[Bug #8730]'
|
|
||||||
e = nil
|
e = nil
|
||||||
assert_raise_with_message(Timeout::Error, /execution expired/, bug8730) do
|
assert_raise_with_message(Timeout::Error, /execution expired/) do
|
||||||
Timeout.timeout 0.01 do
|
Timeout.timeout 0.01 do
|
||||||
begin
|
begin
|
||||||
sleep 3
|
sleep 3
|
||||||
rescue Exception => e
|
rescue => e
|
||||||
flunk "should not see any exception but saw #{e.inspect}"
|
flunk "should not see any exception but saw #{e.inspect}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert_nil(e, bug8730)
|
end
|
||||||
|
|
||||||
|
def test_raises_exception_internally
|
||||||
|
e = nil
|
||||||
|
assert_raise_with_message(Timeout::Error, /execution expired/) do
|
||||||
|
Timeout.timeout 0.01 do
|
||||||
|
begin
|
||||||
|
sleep 3
|
||||||
|
rescue Exception => exc
|
||||||
|
e = exc
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert_equal Timeout::ExitException, e.class
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_rescue_exit
|
def test_rescue_exit
|
||||||
@ -127,11 +153,11 @@ class TestTimeout < Test::Unit::TestCase
|
|||||||
bug11344 = '[ruby-dev:49179] [Bug #11344]'
|
bug11344 = '[ruby-dev:49179] [Bug #11344]'
|
||||||
ok = false
|
ok = false
|
||||||
assert_raise(Timeout::Error) {
|
assert_raise(Timeout::Error) {
|
||||||
Thread.handle_interrupt(Timeout::Error => :never) {
|
Thread.handle_interrupt(Timeout::ExitException => :never) {
|
||||||
Timeout.timeout(0.01) {
|
Timeout.timeout(0.01) {
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
ok = true
|
ok = true
|
||||||
Thread.handle_interrupt(Timeout::Error => :on_blocking) {
|
Thread.handle_interrupt(Timeout::ExitException => :on_blocking) {
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user