Skip to content

Commit

Permalink
Raise exception instead of throw/catch for timeouts (#30)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
jeremyevans and nobu committed Jun 22, 2023
1 parent cae26ed commit f16545a
Show file tree
Hide file tree
Showing 2 changed files with 48 additions and 26 deletions.
34 changes: 15 additions & 19 deletions lib/timeout.rb
Expand Up @@ -25,27 +25,24 @@
module Timeout
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.
class Error < RuntimeError
attr_reader :thread
def self.handle_timeout(message)
exc = ExitException.new(message)

def self.catch(*args)
exc = new(*args)
exc.instance_variable_set(:@thread, Thread.current)
exc.instance_variable_set(:@catch_value, exc)
::Kernel.catch(exc) {yield exc}
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
begin
yield exc
rescue ExitException => e
raise new(message) if exc.equal?(e)
raise
end
super
end
end

Expand Down Expand Up @@ -195,8 +192,7 @@ def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+
if klass
perform.call(klass)
else
backtrace = Error.catch(&perform)
raise Error, message, backtrace
Error.handle_timeout(message, &perform)
end
end
module_function :timeout
Expand Down
40 changes: 33 additions & 7 deletions test/test_timeout.rb
Expand Up @@ -41,25 +41,51 @@ def test_timeout
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
bug3168 = '[ruby-dev:41010]'
def (n = Object.new).zero?; false; end
assert_raise(TypeError, bug3168) {Timeout.timeout(n) { sleep 0.1 }}
end

def test_skip_rescue
bug8730 = '[Bug #8730]'
def test_skip_rescue_standarderror
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
begin
sleep 3
rescue Exception => e
rescue => e
flunk "should not see any exception but saw #{e.inspect}"
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

def test_rescue_exit
Expand Down Expand Up @@ -127,11 +153,11 @@ def test_handle_interrupt
bug11344 = '[ruby-dev:49179] [Bug #11344]'
ok = false
assert_raise(Timeout::Error) {
Thread.handle_interrupt(Timeout::Error => :never) {
Thread.handle_interrupt(Timeout::ExitException => :never) {
Timeout.timeout(0.01) {
sleep 0.2
ok = true
Thread.handle_interrupt(Timeout::Error => :on_blocking) {
Thread.handle_interrupt(Timeout::ExitException => :on_blocking) {
sleep 0.2
}
}
Expand Down

0 comments on commit f16545a

Please sign in to comment.