Project

General

Profile

Actions

Bug #22007

closed

Inconsistent type checking on rescue

Bug #22007: Inconsistent type checking on rescue

Added by zenspider (Ryan Davis) about 1 month ago. Updated 8 days ago.

Status:
Closed
Assignee:
-
Target version:
-
[ruby-core:125311]

Description

this works fine (but I don't think it should):

begin
  raise "nope"
rescue RuntimeError, /why am I allowed?/, "or me?" => e
  # yay
end

whereas this version shows a type error, which seems right:

begin
  begin
    raise "nope"
  rescue /why am I NOT allowed/, "if I was allowed before?" => e
    p e
  end
rescue TypeError => te
  p te
end

I think all the args on rescue should be checked to be Module

Updated by Eregon (Benoit Daloze) about 1 month ago Actions #1 [ruby-core:125313]

The reason is rescue clauses are evaluated lazily, and stop at the first matching one.
I believe this is by design for efficiency.
Evaluating every rescue clause when not necessary would be some overhead and have potentially unwanted side effects (which could even cause compatibility issues).

rescue clauses are arbitrary expressions, so they can have arbitrary side effects.
We can have e.g. rescue RuntimeError, some_method_call_returning_a_class => e and I don't think many would expect some_method_call_returning_a_class to be called needlessly.

We can also think of the case of not raising any exception:

  begin
    p :NO_EXCEPTION
  rescue /why am I NOT allowed/, "if I was allowed before?" => e
    p e
  end

Should that raise TypeError?
If yes the overhead of evaluating these rescue clauses when there is no exception would be very significant.
I would say unacceptable and unexpected.

It would be nice indeed if such invalid rescue clauses are caught early, but the performance cost seems clearly not worth it.
With appropriate test coverage this shouldn't be much of an issue in practice, it's also rather rare to have multiple rescue clauses.

Updated by Eregon (Benoit Daloze) about 1 month ago Actions #2 [ruby-core:125314]

I think a better fix, related to this issue, would be to stop checking the class of rescue clauses, I think any expression should be allowed and just call === on them.

So one could do e.g.:

begin
  raise "nope"
rescue -> e { e.message.include? "o" }
  p :OK
end

Updated by headius (Charles Nutter) about 1 month ago Actions #3 [ruby-core:125315]

I think the better fix would be to reject literal types that are clearly not going to match. Ideally, the only cases that should be admitted to a rescue would be constant accesses, or other expressions that could potentially resolve a type. That cuts out a huge range of literals we can prove will never match an exception.

...unless the goal is to allow rescue to match anything anywhere anytime, in which case every rescue clause must evaluate every expression and make a dynamic method call even when there's practically no chance of it matching.

Personally, I would prefer if the arguments to rescue were either a constant or an opaque expression. 99% of cases will be the constant and we can much more easily optimize for that case.

Updated by zenspider (Ryan Davis) about 1 month ago Actions #4 [ruby-core:125316]

The actual place where this showed up is in assert_raises(*exp) (edited for succinctness):

    def assert_raises *exp
      msg = "#{exp.pop}.\n" if String === exp.last
      exp << StandardError if exp.empty?

      begin
        yield
      rescue *exp => e
        pass # count assertion
        return e
      # ...

where the bug report in question passed: assert_raises(StandardError, %regexp%) { ... } and the regexp went off into the aether w/o a word.

so in the case of lazy evaluation, you're already evaluating exp and then splatting the results into the rescue. At that point, does it actually cost much more to determine that one of the args is a literal (or whatever sort of checks we want to do)?

Updated by zenspider (Ryan Davis) about 1 month ago Actions #5 [ruby-core:125317]

Eregon (Benoit Daloze) wrote in #note-2:

I think a better fix, related to this issue, would be to stop checking the class of rescue clauses, I think any expression should be allowed and just call === on them.

So one could do e.g.:

begin
  raise "nope"
rescue -> e { e.message.include? "o" }
  p :OK
end

so what should rescue /regexp/ do?

Updated by Eregon (Benoit Daloze) about 1 month ago Actions #6 [ruby-core:125318]

zenspider (Ryan Davis) wrote in #note-5:

so what should rescue /regexp/ do?

It would do /regexp/ === exception, which is not useful but consistent.

The assert_raises example is interesting, I recall https://github.com/test-unit/test-unit/issues/347.
Maybe Minitest could have the intuitive semantics so that assert_raises when it sees a non-Module matches that against the exception message?

Updated by kddnewton (Kevin Newton) about 1 month ago Actions #7 [ruby-core:125319]

We can't change this without breaking user code. Things like

irb(main):003> def resolve; SyntaxError; end
=> :resolve
irb(main):004> begin; raise SyntaxError, 'lol'; rescue resolve; end
=> nil
irb(main):005> begin; raise ArgumentError, 'lol'; rescue resolve; end
(irb):5:in '<main>': lol (ArgumentError)

exist. Most expression can potentially resolve to a value, so eliminating the ones that don't doesn't really help. You'd really only be getting rid of things like while loops and that'd be about it.

Updated by byroot (Jean Boussier) about 1 month ago 1Actions #8 [ruby-core:125323]

Eregon (Benoit Daloze) wrote in #note-2:

I think any expression should be allowed and just call === on them.

Agreed. To me rescue is just a shorthand for rescue e; case e; when but that is strangely limited.

I suspect removing that limitation wouldn't prevent JITs from optimizing the overwhelming majority of rescue statements as they'd still only list one of a few classes.

Updated by headius (Charles Nutter) about 1 month ago Actions #9 [ruby-core:125325]

byroot (Jean Boussier) wrote in #note-8:

I suspect removing that limitation wouldn't prevent JITs from optimizing the overwhelming majority of rescue statements as they'd still only list one of a few classes.

Well, it wouldn't be any worse than it is now, but optimizing execution handling is not high on my priority list.

If the rescue clause only has constants, you can guard on those constants staying the same, and whether they use the standard Class#=== implementation and maybe not intercept objects that are not is_a?.

People shouldn't be relying on exceptions to be fast in any case though, because just generating a backtrace is an enormous amount of work.

Updated by matz (Yukihiro Matsumoto) 8 days ago Actions #10 [ruby-core:125507]

  • Status changed from Open to Closed

The current behavior follows from lazy evaluation of rescue clauses, which is intentional. As @kddnewton (Kevin Newton) noted in #note-7, the arguments are expressions and can't be classified statically, so an eager check means evaluating them all, with overhead and side effects.

Generalizing to === on arbitrary expressions is consistent with case/when, but it removes a check that catches real mistakes. The Minitest case in #note-4 is exactly that: a regexp silently never matching. I'd rather keep the TypeError.

For assert_raises, I agree with #note-6 that Minitest can handle non-Module arguments itself.

I will keep the current behavior as is.

Matz.

Actions

Also available in: PDF Atom