Skip to content
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

undefined method `exception' for nil:NilClass with Ruby 3.1.0 #931

Closed
aaronjensen opened this issue Jan 8, 2022 · 37 comments
Closed

undefined method `exception' for nil:NilClass with Ruby 3.1.0 #931

aaronjensen opened this issue Jan 8, 2022 · 37 comments

Comments

@aaronjensen
Copy link
Contributor

* Operating system:                 mac 
* Ruby implementation:             Ruby 3.1.0
* `concurrent-ruby` version:       1.1.9
* `concurrent-ruby-ext` installed:  no
* `concurrent-ruby-edge` used:     no

After upgrading to Ruby 3.1.0 I'm getting a very strange, very hard (for me) to debug error with premailer+sprockets that ultimately manifests with concurrent failing to provide a reason for a promise rejection.

The error is strange enough that it seems like it could be a ruby bug.

When stepping through with byebug I get here:

https://github.com/rails/sprockets/blob/v4.0.2/lib/sprockets/manifest.rb#L143

Then, drilling into asset.source, it returns the source fine. The yield however causes a jump all the way out of the begin in SafeTaskExecutor#execute. If I put an ensure on that begin, it runs. Nothing after @task.call in the block runs. No exception can be caught.

I then get this:

        project/gems/ruby/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:128:in `exception': undefined method `exception' for nil:NilClass

        reason.exception(*args)
              ^^^^^^^^^^ (NoMethodError)
                from project/gems/ruby/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `raise'
                from project/gems/ruby/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `block in wait!'
                from <internal:kernel>:90:in `tap'
                from project/gems/ruby/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `wait!'
                from project/gems/ruby/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:141:in `each'
                from project/gems/ruby/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:141:in `find'
                from project/gems/ruby/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:153:in `each'
                from project/gems/ruby/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:153:in `find_sources'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:13:in `each'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:13:in `first'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:13:in `load'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:48:in `block in load_css'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:46:in `each'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:46:in `load_css'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:20:in `css_for_url'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `block in css_for_doc'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `map'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `css_for_doc'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/customized_premailer.rb:14:in `initialize'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:93:in `new'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:93:in `premailer'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:74:in `generate_html_part'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:52:in `generate_html_part_replacement'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:24:in `perform'
                from project/gems/ruby/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:8:in `perform'
 

I may be able to narrow down a repro if that would be useful, but I wanted to file this now in case there are already any known issues or work arounds I'm not aware of.

Thanks!

@agrberg
Copy link

agrberg commented Jan 8, 2022

Problem

I'm having the same error via premailer-rails as well and have been looking into it a bit myself this morning. My difference is that block_given? in Sprockets::Manifest#find_sources is false so I have an instance of Enumerable in https://github.com/fphilipe/premailer-rails/blob/v1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb#L11. Calling first on that object still causes the same error seen here even though the object responds to the method.

/.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:128:in `exception': undefined method `exception' for nil:NilClass (NoMethodError)

        reason.exception(*args)
              ^^^^^^^^^^
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `raise'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `block in wait!'
	from <internal:kernel>:90:in `tap'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/concurrent-ruby-1.1.9/lib/concurrent-ruby/concurrent/concern/obligation.rb:87:in `wait!'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:130:in `each'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:130:in `find'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:142:in `each'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/sprockets-4.0.2/lib/sprockets/manifest.rb:142:in `find_sources'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:12:in `each'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:12:in `first'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_loaders/asset_pipeline_loader.rb:12:in `load'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:47:in `block in load_css'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:46:in `each'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:46:in `load_css'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:20:in `css_for_url'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `block in css_for_doc'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `map'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:13:in `css_for_doc'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/customized_premailer.rb:14:in `initialize'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:93:in `new'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:93:in `premailer'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:84:in `generate_text_part'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:62:in `generate_alternative_part'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:50:in `generate_html_part_replacement'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:24:in `perform'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/hook.rb:8:in `perform'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/mail-2.7.1/lib/mail/mail.rb:235:in `block in inform_interceptors'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/mail-2.7.1/lib/mail/mail.rb:234:in `each'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/mail-2.7.1/lib/mail/mail.rb:234:in `inform_interceptors'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/mail-2.7.1/lib/mail/message.rb:248:in `inform_interceptors'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/mail-2.7.1/lib/mail/message.rb:258:in `deliver'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/actionmailer-7.0.1/lib/action_mailer/message_delivery.rb:119:in `block in deliver_now'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/actionmailer-7.0.1/lib/action_mailer/rescuable.rb:17:in `handle_exceptions'
	from /.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/actionmailer-7.0.1/lib/action_mailer/message_delivery.rb:118:in `deliver_now'

Workaround

Changing .first to .to_a.first on this line in premailer-rails allows me to work around this issue. I haven't deployed this so I can only confirm it works in development and with my tests.

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Jan 9, 2022

@agrberg I have the exact same circumstances. The same fixes it for me. Though it is called without a block initially, I believe that this line ends up recalling the same method with a block.

This seems like a Ruby bug to me or some breaking change I don't know about.

@aaronjensen
Copy link
Contributor Author

Here is a repro:

https://github.com/aaronjensen/concurrent-repro

Install gems, then run bin/rails c and do:

::Rails.application.assets_manifest.find_sources("application.css").first

@agrberg
Copy link

agrberg commented Jan 9, 2022

I'm still tracking this down in a few spare moments. Via pry-byebug I was able to narrow the cause a bit further. SafeTaskExecutor in Promise#realize is intended to return a triple. However in our case at least both success and reason are nil. This is why there is no method exception on the reason object.

In cases where this is successful, success is true, value is set, and reason is nil. My hunch is that success is intended to be false when a proper reason exists vs. encountering this case where success is the only other falsey value, nil. I'll report back if I have any more luck!

@aaronjensen
Copy link
Contributor Author

@agrberg Right, because for some reason the begin block at

begin
value = @task.call(*args)
success = true
rescue @exception_class => ex
reason = ex
success = false
end
exits without recording an exception or getting to line 24.

The furthest I see things going is https://github.com/rails/sprockets/blob/v4.0.2/lib/sprockets/manifest.rb#L143 and then next thing I know it's returning from SafeTaskExecutor#execute.

@agrberg
Copy link

agrberg commented Jan 10, 2022

I found the same thing! I disabled the source of the error by changing this line to reason&.exception(*args) and now get a clearer error of Premailer::Rails::CSSHelper::FileNotFound specifically:

/.asdf/installs/ruby/3.1.0/lib/ruby/gems/3.1.0/gems/premailer-rails-1.11.1/lib/premailer/rails/css_helper.rb:51:in `load_css': File with URL "/assets/email.debug-#{long_hash}.css" could not be loaded by any strategy. (Premailer::Rails::CSSHelper::FileNotFound)

My hunch is that there is a race condition between this file being requested/accessed as when I "slow" the process down with my workaround, the information is found and everything works.

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Jan 10, 2022

I believe that error only happens because of the thing I described. It is not the actual error as far as I can tell, rather the natural consequence of the asset loader returning nil rather than an asset.

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Jan 10, 2022

I narrowed the repro down to just concurrent-ruby: https://github.com/aaronjensen/concurrent-repro

require "concurrent-ruby"

def sub
  yield
end

def run
  return to_enum(__method__) unless block_given?

  executor = :fast
  # This works
  # executor = :immediate

  promises = [
    Concurrent::Promise.execute(executor: executor) do
      # This version fails on Ruby 3.0.3 as well
      # yield "some-value"

      # This version only fails in Ruby 3.1.0
      sub do |value|
        yield "some-value"
      end
    end
  ]
  promises.each(&:wait!)

  nil
end

# This does not work
run.first

# This works
# run.to_a.first

puts "It worked"

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Jan 10, 2022

This was introduced by this commit:

938e027cdf019ff2cb6ee8a7229e6d9a4d8fc953 is the first bad commit
commit 938e027cdf019ff2cb6ee8a7229e6d9a4d8fc953
Author: Aaron Patterson <tenderlove@ruby-lang.org>
Date:   Tue Jan 26 15:49:21 2021 -0800

    Eliminate useless catch tables and nops from lambdas

cc @tenderlove

@aaronjensen
Copy link
Contributor Author

It's seeming like this is a bug in concurrent-ruby, which does not account for the local jump that happens when an enumerator breaks when first is used. I'll look into seeing how I might fix it, but it would be great for one of the maintainers to chime in if they are available.

@aaronjensen
Copy link
Contributor Author

#932 should fix it

@chrisseaton
Copy link
Member

Thanks will have to find some time to get my head around the issue and the PR.

@agrberg
Copy link

agrberg commented Jan 13, 2022

Awesome job debugging this dude! 🙇

@agrberg
Copy link

agrberg commented Jan 16, 2022

This weekend I upgraded my tiny app to Rails 7 and transitioned from Webpacker to importmaps. I exercised the latter by precompiling my assets locally and stumbled onto another work around when attempting the Ruby 3.1 upgrade. The specific case w/ premailer-rails that @aaronjensen and I have can be avoided in test/development when assets exist on disk! I can also confirm the natural followup question that this isn't a problem in production as assets are statically compiled (I'm using Heroku so YMMV but I'm also not aware of any benefits/strategies where assets aren't precompiled in prod).

The underlying bug still remains where the assets are considered missing when they're intended to be generated dynamically.

@aaronjensen
Copy link
Contributor Author

@chrisseaton have you had a chance to look at this? This is the only thing blocking us from upgrading to 3.1.0 and I'd rather not have to patch premailer-rails, though if you are thinking of not accepting this, please let me know and I can open a PR there to work around this. Thank you!

@thomasvanholder
Copy link

@aaronjensen, did you find a solution to make this work while we wait for the PR to be reviewed?

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Feb 11, 2022

@thomasvanholder because it's ruby, you can redefine SafeTaskExecutor#execute as I defined it in the PR. You can also monkey-patch what's described in the workaround here: #931 (comment)

We have been holding on monkey-patching hoping this gets merged, but we may need to just do it if we want to upgrade Ruby (or submit a workaround to premailer-rails).

@aaronjensen
Copy link
Contributor Author

@eregon it looks like Chris may be busy, would you be able to take a look at this by chance?

@chrisseaton
Copy link
Member

Sorry I will manage to take a look today.

@aaronjensen
Copy link
Contributor Author

No problem, and thank you.

@aaronjensen
Copy link
Contributor Author

Hi @chrisseaton and @eregon sorry to prod, but would it be possible to take a look at this?

@mshappe
Copy link

mshappe commented Feb 24, 2022

It would be really good if this could either move forward, or we could find out what else is being done to unblock Ruby 3.1.0 upgrades...

@chrisseaton
Copy link
Member

Very sorry! I'll definitely take a look in the next 24 hours.

@chrisseaton
Copy link
Member

I can reproduce. Sorry I know that's just a start but evidence I'm on it.

@aaronjensen
Copy link
Contributor Author

Great, thanks @chrisseaton. Not sure if you saw my PR or not, but it fixes it w/ minimal changes and has a test: #932

@tomstuart
Copy link

While there may be an underlying issue in concurrent-ruby that still needs to be addressed, note that sprockets v4.0.3 has just been released, which contains this fix.

@aaronjensen
Copy link
Contributor Author

That’s great news, thank you @tomstuart

@aaronjensen
Copy link
Contributor Author

PR was merged, thanks @chrisseaton

@eregon
Copy link
Collaborator

eregon commented Mar 16, 2022

The test added in #932 is causing various problems, for instance it doesn't work on JRuby (#932), it doesn't work on TruffleRuby (https://github.com/ruby-concurrency/concurrent-ruby/runs/5554594154?check_suite_focus=true), and a fairly small variation of that test segfaults on CRuby 3.0 (https://bugs.ruby-lang.org/issues/18637) or behaves incorrectly (https://bugs.ruby-lang.org/issues/18474).

That test seems too crazy, it's creating an Enumerator with to_enum and yielding to it from another thread. That seems to not make sense and inherently always going to cause problems, so I think we need to fix whatever code would try to do that.

I'm inclined to remove the test (cost is too high compared to value, and it's extremely difficult to understand what the test is trying to do), unless we find a way to rewrite so it doesn't involve a yield from another Thread.

@eregon
Copy link
Collaborator

eregon commented Mar 16, 2022

Sorry, I missed you reported this issue to CRuby, that gives more details: https://bugs.ruby-lang.org/issues/18474

So in CRuby 3.1 and in TruffleRuby, this code gives a LocalJumpError (different messages):

puts RUBY_DESCRIPTION

def execute
  Thread.new do
    yield 42
  end.join
end

p first: to_enum(:execute).first
ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux]
#<Thread:0x00007f9ae70a4590 repro.rb:17 run> terminated with exception (report_on_exception is true):
unexpected break (LocalJumpError)
repro.rb:19:in `join': unexpected break (LocalJumpError)
	from repro.rb:19:in `execute'
	from repro.rb:22:in `each'
	from repro.rb:22:in `first'
	from repro.rb:22:in `<main>'
truffleruby 22.1.0-dev-fc85fe78, like ruby 3.0.2, GraalVM CE Native [x86_64-linux]
#<Thread:0xc8 repro.rb:17 run> terminated with exception:
Traceback (most recent call last):
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'
	from <internal:core> core/exception.rb:105:in `full_message'
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
	from <internal:core> core/exception.rb:105:in `full_message'
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'

I think what we'd need is to tweak the test to just raise LocalJumpError explicitly for clarity and reproducibility (e.g., otherwise it works incorrectly on CRuby 3.0).

@aaronjensen
Copy link
Contributor Author

aaronjensen commented Mar 16, 2022

@eregon If you could instead limit the test to running only on the affected environment (CRuby >= 3.1) then it could maintain the shape of the actual problem. I don't know if just raising LocalJumpError actually does what you would think (I believe I tried that, but I could be mistaken).

@eregon
Copy link
Collaborator

eregon commented Mar 16, 2022

Good point, I'll try to repro the original problem by locally reverting the fix on CRuby 3.1, and see if I can simplify the test.

@eregon
Copy link
Collaborator

eregon commented Mar 18, 2022

So, using the minimal example from mame in https://bugs.ruby-lang.org/issues/18474:

def foo
  Thread.new do
    1.times do
      yield 42
    end
    p "should_not_reach_here"
  end.join
end

p to_enum(:foo).first

Here are the results:

$ ruby -v repro.rb

ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [x86_64-linux]
"should_not_reach_here"
42

ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux]
#<Thread:0x00007f98548c45f8 repro.rb:2 run> terminated with exception (report_on_exception is true):
unexpected break (LocalJumpError)
repro.rb:7:in `join': unexpected break (LocalJumpError)
	from repro.rb:7:in `foo'
	from repro.rb:10:in `each'
	from repro.rb:10:in `first'
	from repro.rb:10:in `<main>'

truffleruby 22.1.0-dev-a5b55044, like ruby 3.0.2, GraalVM CE Native [x86_64-linux]
#<Thread:0xf8 repro.rb:2 run> terminated with exception:
Traceback (most recent call last):
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'
	from <internal:core> core/exception.rb:91:in `full_message'
	from <internal:core> core/truffle/exception_operations.rb:138:in `full_message'
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
	from <internal:core> core/truffle/exception_operations.rb:138:in `full_message'
	from <internal:core> core/exception.rb:91:in `full_message'
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'

jruby 9.3.2.0 (2.6.8) 2021-12-01 0b8223f905 OpenJDK 64-Bit Server VM 11.0.14.1+1 on 11.0.14.1+1 +jit [linux-x86_64]
warning: thread "Ruby-0-Thread-1: repro.rb:1" terminated with exception (report_on_exception is true):
ThreadError: Enumerable#first cannot be parallelized
    foo at repro.rb:4
  times at org/jruby/RubyFixnum.java:302
    foo at repro.rb:3
ThreadError: Enumerable#first cannot be parallelized
    foo at repro.rb:4
  times at org/jruby/RubyFixnum.java:302
    foo at repro.rb:3

CRuby 3.0 behavior is a clear bug, it prints "should_not_reach_here" which is semantically completely broken.
What most likely happens is on CRuby Enumerable#first uses break internally, like if we wrote it like:

  def first
    each { |e| break e } # and somehow return nil if no element not sure how CRuby does that but irrelevant here
  end

and the break, instead of breaking out of that each {} call, it breaks out of the 1.times do call (or the synchronize do call in concurrent-ruby).

CRuby 3.1 doesn't have this bug, instead it's a LocalJumpError which means it can't find where to break, and indeed it would need to break/jump to another thread stack, which is of course impossible.

TruffleRuby also raises a LocalJumpError but it's about return and not break, because first is implemented literally as:

  def first(n=undefined)
    return __take__(n) unless Primitive.undefined?(n)
    each do
      o = Primitive.single_block_arg
      return o
    end
    nil
  end

JRuby uses a separate exception, a ThreadError, which is arguably more helpful, so that behavior sounds fine too.

So it's a bug of CRuby < 3.1.
I still need to check why the test doesn't pass on other impls/versions though.

@eregon
Copy link
Collaborator

eregon commented Mar 18, 2022

If I make a reproducer closer to the actual code, I see that CRuby is still broken :/

def synchronize
  yield
end

def execute(task)
  success = true
  value = reason = nil
  end_sync = false

  synchronize do
    begin
      p :before
      value = task.call
      p :never_reached
      success = true
    rescue StandardError => ex
      p [:rescue, ex]
      reason = ex
      success = false
    end

    end_sync = true
    p :end_sync
  end

  p :should_not_reach_here! unless end_sync
  [success, value, reason]
end

def foo
  Thread.new do
    result = execute(-> { yield 42 })
    p [:result, result]
  end.join
end

p [:first, to_enum(:foo).first]

Results:

ruby 3.0.3p157 (2021-11-24 revision 3fb7d2cadc) [x86_64-linux]
:before
:should_not_reach_here!
[:result, [true, nil, nil]]
[:first, 42]

ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux]
:before
:should_not_reach_here!
[:result, [true, nil, nil]]
[:first, 42]

:before
#<Thread:0xc8 repro2.rb:31 run> terminated with exception:
Traceback (most recent call last):
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'
	from <internal:core> core/exception.rb:91:in `full_message'
	from <internal:core> core/truffle/exception_operations.rb:138:in `full_message'
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
<internal:core> core/thread.rb:135:in `initialize': unexpected return (LocalJumpError)
	from <internal:core> core/truffle/exception_operations.rb:138:in `full_message'
	from <internal:core> core/exception.rb:91:in `full_message'
	from <internal:core> core/truffle/thread_operations.rb:164:in `report_exception'

jruby 9.3.2.0 (2.6.8) 2021-12-01 0b8223f905 OpenJDK 64-Bit Server VM 11.0.14.1+1 on 11.0.14.1+1 +jit [linux-x86_64]
:before
[:rescue, #<ThreadError: Enumerable#first cannot be parallelized>]
:end_sync
[:result, [false, nil, #<ThreadError: Enumerable#first cannot be parallelized>]]
[:first, nil]

CRuby (3.0 and 3.1) print :should_not_reach_here!, which is a semantic bug, if we get to the end of execute we should have gotten to the end of the block given to synchronize.
TruffleRuby prints the LocalJumpError, and then apparently it gets transferred to the main thread, I'm not sure how, but at least it shows the bug in the code.
JRuby does what I expect here, it raises a LocalJumpError early and that's caught by the rescue.

@eregon
Copy link
Collaborator

eregon commented Mar 18, 2022

My conclusion is:

@mshappe
Copy link

mshappe commented Mar 18, 2022

@eregon Thank you for investigating this and for your very complete explanation of what was going on. I suspect CRuby's behaviour comes down to the reality that CRuby's thread handling is, and always has been, not-really-thread handling!

@aaronjensen
Copy link
Contributor Author

Thanks @eregon, that sounds great.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants