-
Notifications
You must be signed in to change notification settings - Fork 23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Raise exception instead of throw/catch for timeouts #30
Conversation
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.
Thank you for creating this. However recently I remembered rails/rails#29333 and IMO it is a good thing that people don't use non-local return and expect that to commit the transaction. def do_throw
DB.transaction do
throw :abort_request # feels like it should NOT commit the transaction isn't it?
end
end
def do_next
DB.transaction do
next # this terminates the block but the caller cannot differentiate with natural return, so this can only commit
end
end
def do_break
DB.transaction do
break # this "breaks" from the transaction, it feels like it should not commit, i.e., it should rollback
end
end
def do_return
DB.transaction do
return if ... # this is probably less clear, but IMO it's really bad style to do this, it is not letting the transaction end normally but instead "escaping" from the transaction, in that POV it should rollback
end
end The result of these depend on the implementation of class DB
def self.transaction
yield
rescue Exception => exc
p :ROLLBACK
ensure
p :COMMIT unless exc
end
end the result is:
When using this: class DB
def self.transaction
yield
success = true
ensure
if success
p :COMMIT
else
p :ROLLBACK
end
end
end the result is:
which I think is much better, it commits on Does anyone expect a transaction to be committed if there was a So the current implementation using throw, although to some degree unexpected, has the advantage to "tell" developers to use IMO more correct handling of non-local jumps. With this PR, it is of course still possible to use the 2nd implementation of cc @ioquatix IIRC you were also discussing this matter on the Ruby bug tracker. |
Interesting. And I guess |
I believe I am in favour of this PR, but I have not reviewed it in detail. There are some nuances of whether one returns a subclass of IIRC, we tried both in Celluloid, and found that |
Timeout has always been a problematic library. It can introduce unexpected exceptions into any code flow at any time and Since that is unlikely to happen, however, I also agree that using throw/catch is not a good choice for the library. When it was introduced into the library, JRuby chose not to follow that path. We only picked it up once we were forced to share the same code in the timeout gem. I would have no objections to returning to a normal exception, and it would not have any effect on how JRuby supports this library. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good but I'd like to know that we've tested this against some top-10 gems.
@jeremyevans @ioquatix @headius and others: I'd like to get your opinions on this part of my (long) comment above:
If we agree |
The question is not whether The problem here is that the cross-thread exception raising mechanism is being subverted to introduce So the answer to your question is that |
@headius I'm not sure I get your point, both My question is given a |
I guess with your choice of symbol, yes. However, consider: def do_throw
DB.transaction do
throw :commit_request # feels like it should commit the transaction, right?
end
end This is equally valid. In general, throw/catch is always used for expected control flow, and not for exceptional control flow. For one, using throw without a matching catch raises an UncaughtThrowError exception.
I would expect this to commit.
I would expect this to commit, as break/next is used for expected control flow, not for exceptional control flow.
I would expect this to commit. I don't consider this bad style. It's quite common to use early returns, and again, they are used for expected control flow, not for exceptional control flow.
I think rolling back in any of these examples is a bad idea. Only raise (exceptional control flow) should rollback. All other control flow is expected control flow and should commit by default.
I expect transactions to be committed on throw (that is how Sequel works). throw/catch is for non-exceptional, non-local control flow, same as early return/next. When using Sinatra/Roda/Cuba/Syro and other frameworks, throw/catch is used to immediately return responses anytime during request processing. I believe all of these frameworks use throw/catch to handle responses for all requests, not just for some requests. For Sinatra, see https://github.com/sinatra/sinatra/blob/5f4dde19719505989905782a61a19c545df7f9f9/lib/sinatra/base.rb#L1058-L1086 .
I think you are incorrect here. How much have you developed web applications in Sinatra? I used Sinatra as my primary web framework for over 5 years, and it's standard in Sinatra to use halt/redirect for successful conditions (also possible to use next for early returns as well). Example: post '/' do
DB.transaction do
case params['foo']
when 'bar-baz'
Bar[1].update(...)
Baz[2].update(...)
redirect '/bar' # uses throw
when 'baz-bar'
Baz[1].update(...)
Bar[2].update(...)
redirect '/baz' # uses throw
end
end
end
I disagree that your examples here are more correct, so I disagree that this is an advantage of timeout's current implementation.
I disagree. Only exceptional control flow should rollback automatically. All expected control flow should not. Regardless of your opinion on the implementation of a transaction method, I hope we can agree that a timeout error is exceptional control flow and not expected control flow, and should therefore use raise and not throw. |
I agree. That's why we never modified JRuby's version of timeout to use throw/catch. |
@jeremyevans Thank you for your reply. I like this
Thanks for clarifying that. I have used Sinatra several times but not daily. I just thought I think I think I'm OK with the change, my main concern is the compatibility issue with #30 (comment). To be fair |
In non-test usages I have found:
|
Personally I find it amusing that while we're talking about a library that violently interrupts the execution of user code there's concerns about that user code preventing us from doing so. It seems to me that what's good for the goose is good for the gander: if a user or a library author decides that intercepting timeout errors is the right thing to do, that's no more egregious a decision than us throwing unexpected exceptions at them. To be sure, making it possible to gracefully handle (or ignore) timeout errors is the essence of Ruby; part of my issue with throw/catch was that there's no way for users to enlist in the process and handle it their own way. Perhaps there will be poorly-behaved libraries that defeat timeout altogether. That's their prerogative, and if it affects users they will not use said library. But it's not the Ruby way for us to force this flow interruption onto code and also prevent them from overriding it. |
I agree. I looked in
Using throw for all responses seems odd until you get used to it, similar to how dynamic typing seems odd if you are only used to static typing. However, once you get used to it, I think you'll find that using throw for all responses allows for simpler code in the long run, and is one of the reasons that Sinatra/Roda are simpler to use than frameworks that don't throw/catch for responses.
Since it looks like there isn't that many libraries affected by this change, I can notify both projects if this pull request is accepted, and work with them to get their code updated before Ruby 3.3 is released. |
Also this is officially documented in https://github.com/ruby/ruby/blob/31b28b31fa5a0452cb9d5f7eee88eebfebe5b4d1/thread.c#L2067-L2089, these docs will need to be adapted as well. |
lib/timeout.rb
Outdated
yield exc | ||
rescue ExitException => e | ||
raise new(message) unless exc.equal?(e) | ||
super |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does super
do here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That should be fixed. It should raise
instead. I'll push a fix. This is necessary for some nested timeouts, so only convert from Timeout::ExitException to Timeout::Error for the matching Timeout.timeout call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, could you add a test for this? I assume there isn't one since it would have failed with the super
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I added a nested timeouts test which handles this case.
lib/timeout.rb
Outdated
# Return the receiver if it the exception is triggered in | ||
# the same thread. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this or could we just assume the ExitException we rescue
is the one we raised? Maybe it's necessary to handle the case of nested timeouts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs say:
=== Implementation from Exception
------------------------------------------------------------------------
exc.exception([string]) -> an_exception or exc
------------------------------------------------------------------------
With no argument, or if the argument is the same as the receiver, return
the receiver. Otherwise, create a new exception object of the same class
as the receiver, but with a message equal to string.to_str.
so I think we don't need to do anything special here (it should already return the receiver).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Turns out the #exception
define is needed, because the interrupt
method does @thread.raise @exception_class, @message
. Without the @message
, it wouldn't be needed. However, the @message
is needed in the cases where you are providing a klass
argument to timeout
.
Here is gem-codesearch:
So libhoney and pollter_geist seem potentially affected, the rest is just vendored CRuby code/docs. |
ExitException#exception needs to always return self, so the same exception object will be used. The previous code never used the same exception object, but the conditional was inverted from what it should have been, so all existing tests passed. There wasn't a test for nested timeouts, so I added one. Remove the @thread instance variable setting. When using Thread#raise, it is expected that the Thread that raises the exception will be different than the thread that created the exception.
I discussed this PR with Jeremy. If we accept this PR, I will follow up with a 2nd PR to align the fiber scheduler implementation logic more closely with this PR (so please don't do an immediate release after merging this PR). As it stands:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm OK with this but I would like @nobu's review as well.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems to add a new public interface Timeout::ExitException
.
Does it need to be public?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does if we want to allow users to use Thread.handle_interrupt
with it. From @eregon's research, there are only 2 gems actually doing that, and whether it is actually needed is not known. If we decide we don't want to support that, we could make ExitException
a private constant.
I have a test suite I'm getting ready to submit as a PR. It's not ready yet, but I wanted to use it to flag a behavior change from this PR, just in case it's interesting / wasn't intended. For the scenario when an exception to raise is not specified and the inner code does catch Exception, I've marked in comments here what changed between 0.2 and 0.4 (the two i tested) timeout/test/test_error_lifecycle.rb Lines 43 to 46 in 096d15e
You can see inner_rescue and outer_rescue referred here timeout/test/lib/error_lifecycle.rb Lines 26 to 36 in e7ae1b3
I believe the change to the inner behavior is the purpose of this PR (30). I'm not sure if the outer behavior change was intended. |
If you |
gotcha, thanks for the explanation! |
couple questions if you don't mind:
Does this mean, there's no way to implement this behavior in timeout, but it is theoretically desired? Or, given other semantic constraints, this behavior is an implication.
When you say "any such code", are you referring to client code using timeout (it's that code's responsibility to know it's in a timeout block) or are you referring to a potential timeout modification which would change the behavior? |
It's not desired. Timeout errors must result in related The
I'm referring to any code that uses |
So I think you are saying that it's not desired because the only way to do it is to defeat inner code behavior, and doing so is not acceptable, so therefore attempting this behavior (outer code always gets an exception when time has lapsed) is not desired. the prerequisite is not desired. I think I did achieve both things in a older implementation of timeout here: jjb/better_timeout@a5282a2 And the current implementation of timeout here: https://github.com/jjb/timeout/pull/4/files Maybe now is not the time to dive into the pros/cons of that approach, but I just wanted to call it out while fresh on our minds. I want to get feedback on that approach eventually, but first waiting on possible merge of my tests-only PR #33 |
Assuming I'm reading this correctly (jjb/better_timeout@a5282a2#diff-5fa5bc762e29d9e8a89e396dff49339c77d3ed55d4ac349e1066f324b24dce71R59), your code has #timeout yield in a separate thread (unless a nil/0 timeout is passed, in which case it yields in the current thread), which would not be acceptable as it would break code that depends on the current thread (such as thread-based connection pools by common database libraries). |
Thanks for the explanation of that requirement! Do you know if that requirement is currently represented in a test in the current test suite? I would love to help writing one if not. "depends on the current thread" - depends on what about the current thread? it continuing? |
Not sure, but a test would be: assert_equal Thread.current, Timeout.timeout(10){Thread.current}
Depends on the current thread inside the timeout block being the same as the current thread outside the timeout block. |
see discussion in ruby#30 (comment)
Great - made a PR if interested #34 |
* see discussion in #30 (comment)
the caller (ruby/timeout#34) * see discussion in ruby/timeout#30 (comment)
Fix: rails#45017 Ref: rails#29333 Ref: ruby/timeout#30 Historically only raised errors would trigger a rollback, but in Ruby `2.3`, the `timeout` library started using `throw` to interupt execution which had the adverse effect of committing open transactions. To solve this, in Active Record 6.1 the behavior was changed to instead rollback the transaction as it was safer than to potentially commit an incomplete transaction. Using `return`, `break` or `throw` inside a `transaction` block was essentially deprecated from Rails 6.1 onwards. However with the release of `timeout 0.4.0`, `Timeout.timeout` now raises an error again, and Active Record is able to return to its original, less surprising, behavior.
* This PR from the timeout gem (ruby/timeout#30) made it so you have to handle_interrupt on Timeout::ExitException instead of Timeout::Error * Top-level #timeout was removed from the timeout gem 5 years ago - it needs to be Timeout.timeout
* This PR from the timeout gem (ruby/timeout#30) made it so you have to handle_interrupt on Timeout::ExitException instead of Timeout::Error * Efficiency changes to the gem (one shared thread) mean you can't consistently handle timeout errors using handle_timeout: ruby/timeout#41
* This PR from the timeout gem (ruby/timeout#30) made it so you have to handle_interrupt on Timeout::ExitException instead of Timeout::Error * Efficiency changes to the gem (one shared thread) mean you can't consistently handle timeout errors using handle_timeout: ruby/timeout#41
* This PR from the timeout gem (ruby/timeout#30) made it so you have to handle_interrupt on Timeout::ExitException instead of Timeout::Error * Efficiency changes to the gem (one shared thread) mean you can't consistently handle timeout errors using handle_timeout: ruby/timeout#41
* This PR from the timeout gem (ruby/timeout#30) made it so you have to handle_interrupt on Timeout::ExitException instead of Timeout::Error * Efficiency changes to the gem (one shared thread) mean you can't consistently handle timeout errors using handle_timeout: ruby/timeout#41
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:
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.